Skip to content

Commit

Permalink
Use sudo-prompt to re-run tsrelay in Linux (#64)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
marwan-at-work authored and tylersmalley committed Jun 21, 2023
1 parent a08e30d commit 1d70ff2
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 103 deletions.
20 changes: 20 additions & 0 deletions src/serve-panel-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
151 changes: 111 additions & 40 deletions src/tailscale/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,22 +42,9 @@ export class Tailscale {
return ts;
}

async init() {
async init(port?: string, nonce?: string) {
return new Promise<null>((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');
Expand All @@ -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) => {
Expand All @@ -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);

Expand All @@ -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<null>((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();
}
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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());
Expand Down
17 changes: 14 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ export interface Handlers {
Proxy: string;
}

export interface ServeStatus {
export interface ServeStatus extends WithErrors {
ServeConfig?: ServeConfig;
FunnelPorts?: number[];
Services: {
[port: number]: string;
};
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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -156,4 +166,5 @@ export interface NewPortNotification {
export interface TSRelayDetails {
address: string;
nonce: string;
port: string;
}
5 changes: 1 addition & 4 deletions src/webviews/serve-panel/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
50 changes: 19 additions & 31 deletions src/webviews/serve-panel/simple-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<div className="flex mt-2 bg-bannerBackground p-3 text-bannerForeground">
<div className="pr-2 codicon codicon-terminal-linux !text-xl"></div>
<div className=" text-2lg">
<div className="font-bold ">Notice for Linux users</div>
<div>
We're working to resolve an issue preventing this extension from being used on Linux.
<br />
However, you can still use Funnel from the CLI.
</div>
<div className="mt-4">
<VSCodeButton
appearance="primary"
onClick={() => vsCodeAPI.openLink('https://tailscale.com/kb/1223/tailscale-funnel')}
>
See CLI docs
</VSCodeButton>
</div>
</div>
</div>
);
}

return (
<div>
{data?.Errors?.map((error, index) => (
Expand All @@ -111,7 +86,6 @@ export const SimpleView = () => {

return (
<form onSubmit={handleSubmit}>
<div></div>
<div className="w-full flex flex-col md:flex-row">
<div className={`p-3 flex items-center flex-0 ${hasServeTextStyle}`}>
<span
Expand Down Expand Up @@ -217,10 +191,16 @@ export const SimpleView = () => {

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);
Expand All @@ -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,
});
}
}
};
Loading

0 comments on commit 1d70ff2

Please sign in to comment.