From 1d70ff296f4498d9c76444fab2dea6524c89cabf Mon Sep 17 00:00:00 2001 From: Marwan Sulaiman Date: Wed, 21 Jun 2023 20:54:09 +0200 Subject: [PATCH] Use sudo-prompt to re-run tsrelay in Linux (#64) This (draft) PR prompts the user to enter their password for Linux when tsrelay is needed to run with `sudo`. Note that for now it assumes Linux already has `pkexec` installed. Currently, it keeps the same nonce and port so that we don't need to let the client reconfigure its attributes. --- src/serve-panel-provider.ts | 20 +++ src/tailscale/cli.ts | 151 +++++++++++++++++------ src/types.ts | 17 ++- src/webviews/serve-panel/data.tsx | 5 +- src/webviews/serve-panel/simple-view.tsx | 50 +++----- tsrelay/main.go | 90 ++++++++++---- 6 files changed, 230 insertions(+), 103 deletions(-) diff --git a/src/serve-panel-provider.ts b/src/serve-panel-provider.ts index 4a65682..7b4c3cd 100644 --- a/src/serve-panel-provider.ts +++ b/src/serve-panel-provider.ts @@ -99,6 +99,26 @@ export class ServePanelProvider implements vscode.WebviewViewProvider { break; } + case 'sudoPrompt': { + Logger.info('running tsrelay in sudo'); + try { + await this.ts.initSudo(); + Logger.info(`re-applying ${m.operation}`); + if (m.operation == 'add') { + if (!m.params) { + Logger.error('params cannot be null for an add operation'); + return; + } + await this.ts.serveAdd(m.params); + } else if (m.operation == 'delete') { + await this.ts.serveDelete(); + } + } catch (e) { + Logger.error(`error running sudo prompt: ${e}`); + } + break; + } + default: { console.log('Unknown type for message', m); } diff --git a/src/tailscale/cli.ts b/src/tailscale/cli.ts index 13c18e9..86f5bfa 100644 --- a/src/tailscale/cli.ts +++ b/src/tailscale/cli.ts @@ -27,8 +27,10 @@ export class Tailscale { private _vscode: vscodeModule; private nonce?: string; public url?: string; + private port?: string; public authkey?: string; private childProcess?: cp.ChildProcess; + private notifyExit?: () => void; constructor(vscode: vscodeModule) { this._vscode = vscode; @@ -40,22 +42,9 @@ export class Tailscale { return ts; } - async init() { + async init(port?: string, nonce?: string) { return new Promise((resolve) => { - let arch = process.arch; - let platform: string = process.platform; - // See: - // https://goreleaser.com/customization/builds/#why-is-there-a-_v1-suffix-on-amd64-builds - if (process.arch === 'x64') { - arch = 'amd64_v1'; - } - if (platform === 'win32') { - platform = 'windows'; - } - let binPath = path.join( - __dirname, - `../bin/vscode-tailscale_${platform}_${arch}/vscode-tailscale` - ); + let binPath = this.tsrelayPath(); let args = []; if (this._vscode.env.logLevel === LogLevel.Debug) { args.push('-v'); @@ -66,12 +55,22 @@ export class Tailscale { args = ['run', '.', ...args]; cwd = path.join(cwd, '../tsrelay'); } - Logger.info(`path: ${binPath}`, LOG_COMPONENT); + if (port) { + args.push(`-port=${this.port}`); + } + if (nonce) { + args.push(`-nonce=${this.nonce}`); + } + Logger.debug(`path: ${binPath}`, LOG_COMPONENT); + Logger.debug(`args: ${args.join(' ')}`, LOG_COMPONENT); this.childProcess = cp.spawn(binPath, args, { cwd: cwd }); this.childProcess.on('exit', (code) => { Logger.warn(`child process exited with code ${code}`, LOG_COMPONENT); + if (this.notifyExit) { + this.notifyExit(); + } }); this.childProcess.on('error', (err) => { @@ -83,6 +82,7 @@ export class Tailscale { const details = JSON.parse(data.toString().trim()) as TSRelayDetails; this.url = details.address; this.nonce = details.nonce; + this.port = details.port; this.authkey = Buffer.from(`${this.nonce}:`).toString('base64'); Logger.info(`url: ${this.url}`, LOG_COMPONENT); @@ -100,40 +100,108 @@ export class Tailscale { throw new Error('childProcess.stdout is null'); } - if (this.childProcess.stderr) { - let buffer = ''; - this.childProcess.stderr.on('data', (data: Buffer) => { - buffer += data.toString(); // Append the data to the buffer + this.processStderr(this.childProcess); + }); + } - const lines = buffer.split('\n'); // Split the buffer into lines + processStderr(childProcess: cp.ChildProcess) { + if (!childProcess.stderr) { + Logger.error('childProcess.stderr is null', LOG_COMPONENT); + throw new Error('childProcess.stderr is null'); + } + let buffer = ''; + childProcess.stderr.on('data', (data: Buffer) => { + buffer += data.toString(); // Append the data to the buffer - // Process all complete lines except the last one - for (let i = 0; i < lines.length - 1; i++) { - const line = lines[i].trim(); - if (line.length > 0) { - Logger.info(line, LOG_COMPONENT); - } - } + const lines = buffer.split('\n'); // Split the buffer into lines - buffer = lines[lines.length - 1]; - }); + // Process all complete lines except the last one + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + if (line.length > 0) { + Logger.info(line, LOG_COMPONENT); + } + } + + buffer = lines[lines.length - 1]; + }); + + childProcess.stderr.on('end', () => { + // Process the remaining data in the buffer after the stream ends + const line = buffer.trim(); + if (line.length > 0) { + Logger.info(line, LOG_COMPONENT); + } + }); + } - this.childProcess.stderr.on('end', () => { - // Process the remaining data in the buffer after the stream ends - const line = buffer.trim(); - if (line.length > 0) { - Logger.info(line, LOG_COMPONENT); + async initSudo() { + return new Promise((resolve, err) => { + const binPath = this.tsrelayPath(); + const args = [`-nonce=${this.nonce}`, `-port=${this.port}`]; + if (this._vscode.env.logLevel === LogLevel.Debug) { + args.push('-v'); + } + Logger.info(`path: ${binPath}`, LOG_COMPONENT); + this.notifyExit = () => { + Logger.info('starting sudo tsrelay'); + const childProcess = cp.spawn(`/usr/bin/pkexec`, [ + '--disable-internal-agent', + binPath, + ...args, + ]); + childProcess.on('exit', async (code) => { + Logger.warn(`sudo child process exited with code ${code}`, LOG_COMPONENT); + if (code === 0) { + return; + } else if (code === 126) { + // authentication not successful + this._vscode.window.showErrorMessage( + 'Creating a Funnel must be done by an administrator' + ); + } else { + this._vscode.window.showErrorMessage('Could not run authenticator, please check logs'); } + await this.init(this.port, this.nonce); + err('unauthenticated'); }); - } else { - Logger.error('childProcess.stderr is null', LOG_COMPONENT); - throw new Error('childProcess.stderr is null'); - } + childProcess.on('error', (err) => { + Logger.error(`sudo child process error ${err}`, LOG_COMPONENT); + }); + childProcess.stdout.on('data', (data: Buffer) => { + Logger.debug('received data from sudo'); + const details = JSON.parse(data.toString().trim()) as TSRelayDetails; + if (this.url !== details.address) { + Logger.error(`expected url to be ${this.url} but got ${details.address}`); + return; + } + this.runPortDisco(); + Logger.debug('resolving'); + resolve(null); + }); + this.processStderr(childProcess); + }; + this.dispose(); }); } + tsrelayPath(): string { + let arch = process.arch; + let platform: string = process.platform; + // See: + // https://goreleaser.com/customization/builds/#why-is-there-a-_v1-suffix-on-amd64-builds + if (process.arch === 'x64') { + arch = 'amd64_v1'; + } + if (platform === 'win32') { + platform = 'windows'; + } + return path.join(__dirname, `../bin/vscode-tailscale_${platform}_${arch}/vscode-tailscale`); + } + dispose() { if (this.childProcess) { + Logger.info('shutting down tsrelay'); this.childProcess.kill(); } } @@ -227,7 +295,7 @@ export class Tailscale { const ws = new WebSocket(`ws://${this.url.slice('http://'.length)}/portdisco`, { headers: { - Authorization: 'Basic ' + Buffer.from(`${this.nonce}:`).toString('base64'), + Authorization: 'Basic ' + this.authkey, }, }); ws.on('error', (e) => { @@ -249,6 +317,9 @@ export class Tailscale { ); }); }); + ws.on('close', () => { + Logger.info('websocket is closed'); + }); ws.on('message', async (data) => { Logger.info('got message'); const msg = JSON.parse(data.toString()); diff --git a/src/types.ts b/src/types.ts index a56b908..29b194a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,7 +19,7 @@ export interface Handlers { Proxy: string; } -export interface ServeStatus { +export interface ServeStatus extends WithErrors { ServeConfig?: ServeConfig; FunnelPorts?: number[]; Services: { @@ -27,11 +27,14 @@ export interface ServeStatus { }; BackendState: string; Self: PeerStatus; +} + +export interface WithErrors { Errors?: RelayError[]; } interface RelayError { - Type: string; + Type: 'FUNNEL_OFF' | 'HTTPS_OFF' | 'OFFLINE' | 'REQUIRES_SUDO' | 'NOT_RUNNING'; } interface PeerStatus { @@ -118,7 +121,14 @@ export type Message = | ResetServe | SetFunnel | WriteToClipboard - | OpenLink; + | OpenLink + | SudoPrompt; + +interface SudoPrompt { + type: 'sudoPrompt'; + operation: 'add' | 'delete'; + params?: ServeParams; +} /** * Messages sent from the extension to the webview. @@ -156,4 +166,5 @@ export interface NewPortNotification { export interface TSRelayDetails { address: string; nonce: string; + port: string; } diff --git a/src/webviews/serve-panel/data.tsx b/src/webviews/serve-panel/data.tsx index 4c8a02b..2fcf83f 100644 --- a/src/webviews/serve-panel/data.tsx +++ b/src/webviews/serve-panel/data.tsx @@ -28,8 +28,5 @@ export async function fetchWithUser(path: string, options: RequestInit = {}) { console.time(path); const res = await fetch(url + path, options); console.timeEnd(path); - - if (options.method !== 'DELETE') { - return res.json(); - } + return res.json(); } diff --git a/src/webviews/serve-panel/simple-view.tsx b/src/webviews/serve-panel/simple-view.tsx index 6d916c4..a5f4803 100644 --- a/src/webviews/serve-panel/simple-view.tsx +++ b/src/webviews/serve-panel/simple-view.tsx @@ -8,6 +8,7 @@ import { KB_FUNNEL_USE_CASES } from '../../utils/url'; import { useServe, useServeMutation, fetchWithUser } from './data'; import { Tooltip } from './components/tooltip'; import { errorForType } from '../../tailscale/error'; +import { ServeParams, WithErrors } from '../../types'; export const SimpleView = () => { const { data, mutate, isLoading } = useServe(); @@ -60,32 +61,6 @@ export const SimpleView = () => { const textStyle = 'text-bannerForeground bg-bannerBackground'; const textDisabledStyle = 'text-foreground bg-background'; const hasServeTextStyle = persistedPort ? textStyle : textDisabledStyle; - - // sorry Linux - if (window.tailscale.platform === 'linux') { - return ( -
-
-
-
Notice for Linux users
-
- We're working to resolve an issue preventing this extension from being used on Linux. -
- However, you can still use Funnel from the CLI. -
-
- vsCodeAPI.openLink('https://tailscale.com/kb/1223/tailscale-funnel')} - > - See CLI docs - -
-
-
- ); - } - return (
{data?.Errors?.map((error, index) => ( @@ -111,7 +86,6 @@ export const SimpleView = () => { return (
-
{ setIsDeleting(true); - await fetchWithUser('/serve', { + const resp = (await fetchWithUser('/serve', { method: 'DELETE', body: '{}', - }); + })) as WithErrors; + if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { + vsCodeAPI.postMessage({ + type: 'sudoPrompt', + operation: 'delete', + }); + } setIsDeleting(false); setPreviousPort(port); @@ -240,12 +220,20 @@ export const SimpleView = () => { return; } - await trigger({ + const params: ServeParams = { protocol: 'https', port: 443, mountPoint: '/', source: `http://127.0.0.1:${port}`, funnel: true, - }); + }; + const resp = (await trigger(params)) as WithErrors; + if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') { + vsCodeAPI.postMessage({ + type: 'sudoPrompt', + operation: 'add', + params, + }); + } } }; diff --git a/tsrelay/main.go b/tsrelay/main.go index fc02ea3..a6488f7 100644 --- a/tsrelay/main.go +++ b/tsrelay/main.go @@ -50,9 +50,12 @@ const ( // Offline can mean a user is not logged in // or is logged in but their key has expired. Offline = "OFFLINE" - // RequiredSudo for when LocalBackend is run + // RequiresSudo for when LocalBackend is run // with sudo but tsrelay is not - RequiredSudo = "REQUIRES_SUDO" + RequiresSudo = "REQUIRES_SUDO" + // NotRunning indicates tailscaled is + // not running + NotRunning = "NOT_RUNNING" ) func main() { @@ -84,6 +87,7 @@ func run() error { type serverDetails struct { Address string `json:"address,omitempty"` Nonce string `json:"nonce,omitempty"` + Port string `json:"port,omitempty"` } func runHTTPServer(ctx context.Context, lggr *logger, port int, nonce string) error { @@ -95,10 +99,13 @@ func runHTTPServer(ctx context.Context, lggr *logger, port int, nonce string) er if err != nil { return fmt.Errorf("error parsing addr %q: %w", l.Addr().String(), err) } - sd := serverDetails{Address: fmt.Sprintf("http://127.0.0.1:%s", u.Port())} if nonce == "" { nonce = getNonce() - sd.Nonce = nonce // only print it out if not set by flag + } + sd := serverDetails{ + Address: fmt.Sprintf("http://127.0.0.1:%s", u.Port()), + Port: u.Port(), + Nonce: nonce, } json.NewEncoder(os.Stdout).Encode(sd) s := &http.Server{ @@ -113,7 +120,7 @@ func runHTTPServer(ctx context.Context, lggr *logger, port int, nonce string) er onPortUpdate: func() {}, }, } - return serveTLS(ctx, l, s, time.Second) + return serve(ctx, lggr, l, s, time.Second) } func getNonce() string { @@ -230,12 +237,20 @@ func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 500) return } + w.Write([]byte(`{}`)) case http.MethodDelete: if err := h.deleteServe(r.Context(), r.Body); err != nil { + var re RelayError + if errors.As(err, &re) { + w.WriteHeader(re.statusCode) + json.NewEncoder(w).Encode(re) + return + } h.l.Println("error deleting serve:", err) http.Error(w, err.Error(), 500) return } + w.Write([]byte(`{}`)) case http.MethodGet: var wg sync.WaitGroup wg.Add(1) @@ -260,11 +275,7 @@ func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if errors.As(err, &oe) && oe.Op == "dial" { w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(RelayError{ - Errors: []Error{ - { - Type: "NOT_RUNNING", - }, - }, + Errors: []Error{{Type: NotRunning}}, }) } else { http.Error(w, err.Error(), 500) @@ -361,10 +372,17 @@ func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } err := h.setFunnel(r.Context(), r.Body) if err != nil { + var re RelayError + if errors.As(err, &re) { + w.WriteHeader(re.statusCode) + json.NewEncoder(w).Encode(re) + return + } h.l.Println("error toggling funnel:", err) http.Error(w, err.Error(), 500) return } + w.Write([]byte(`{}`)) case "/portdisco": h.portDiscoHandler(w, r) default: @@ -419,20 +437,17 @@ func (h *httpHandler) getConfigs(ctx context.Context) (*ipnstate.Status, *ipn.Se } func (h *httpHandler) deleteServe(ctx context.Context, body io.Reader) error { var req serveRequest - - if body == nil || body == http.NoBody { - body = io.NopCloser(strings.NewReader("{}")) - } - - err := json.NewDecoder(body).Decode(&req) - if err != nil { - return fmt.Errorf("error decoding request body: %w", err) + if body != nil && body != http.NoBody { + err := json.NewDecoder(body).Decode(&req) + if err != nil { + return fmt.Errorf("error decoding request body: %w", err) + } } // reset serve config if no request body if (req == serveRequest{}) { sc := &ipn.ServeConfig{} - err = h.lc.SetServeConfig(ctx, sc) + err := h.setServeCfg(ctx, sc) if err != nil { return fmt.Errorf("error setting serve config: %w", err) } @@ -452,7 +467,7 @@ func (h *httpHandler) deleteServe(ctx context.Context, body io.Reader) error { if len(sc.AllowFunnel) == 0 { sc.AllowFunnel = nil } - err = h.lc.SetServeConfig(ctx, sc) + err = h.setServeCfg(ctx, sc) if err != nil { return fmt.Errorf("error setting serve config: %w", err) } @@ -484,7 +499,7 @@ func (h *httpHandler) createServe(ctx context.Context, body io.Reader) error { } else { delete(sc.AllowFunnel, hostPort) } - err = h.lc.SetServeConfig(ctx, sc) + err = h.setServeCfg(ctx, sc) if err != nil { if tailscale.IsAccessDeniedError(err) { cfgJSON, err := json.Marshal(sc) @@ -494,7 +509,7 @@ func (h *httpHandler) createServe(ctx context.Context, body io.Reader) error { re := RelayError{ statusCode: http.StatusForbidden, Errors: []Error{{ - Type: RequiredSudo, + Type: RequiresSudo, Command: fmt.Sprintf(`echo %s | sudo tailscale serve --set-raw`, cfgJSON), }}, } @@ -535,13 +550,38 @@ func (h *httpHandler) setFunnel(ctx context.Context, body io.Reader) error { sc.AllowFunnel = nil } } - err = h.lc.SetServeConfig(ctx, sc) + err = h.setServeCfg(ctx, sc) if err != nil { return fmt.Errorf("error setting serve config: %w", err) } return nil } +func (h *httpHandler) setServeCfg(ctx context.Context, sc *ipn.ServeConfig) error { + err := h.lc.SetServeConfig(ctx, sc) + if err != nil { + if tailscale.IsAccessDeniedError(err) { + cfgJSON, err := json.Marshal(sc) + if err != nil { + return fmt.Errorf("error marshaling own config: %w", err) + } + re := RelayError{ + statusCode: http.StatusForbidden, + Errors: []Error{{ + Type: RequiresSudo, + Command: fmt.Sprintf(`echo %s | sudo tailscale serve --set-raw`, cfgJSON), + }}, + } + return re + } + if err != nil { + return fmt.Errorf("error marshaling config: %w", err) + } + return fmt.Errorf("error setting serve config: %w", err) + } + return nil +} + func setHandler(sc *ipn.ServeConfig, newHP ipn.HostPort, req serveRequest) { if sc.TCP == nil { sc.TCP = make(map[uint16]*ipn.TCPPortHandler) @@ -601,8 +641,7 @@ func must(err error) { } } -// serveTLS is like Serve but calls serveTLS instead. -func serveTLS(ctx context.Context, l net.Listener, s *http.Server, timeout time.Duration) error { +func serve(ctx context.Context, lggr *logger, l net.Listener, s *http.Server, timeout time.Duration) error { serverErr := make(chan error, 1) go func() { // Capture ListenAndServe errors such as "port already in use". @@ -614,6 +653,7 @@ func serveTLS(ctx context.Context, l net.Listener, s *http.Server, timeout time. var err error select { case <-ctx.Done(): + lggr.Println("received interrupt signal") ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() err = s.Shutdown(ctx)