Skip to content

Commit 1d70ff2

Browse files
marwan-at-worktylersmalley
authored andcommitted
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.
1 parent a08e30d commit 1d70ff2

File tree

6 files changed

+230
-103
lines changed

6 files changed

+230
-103
lines changed

src/serve-panel-provider.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ export class ServePanelProvider implements vscode.WebviewViewProvider {
9999
break;
100100
}
101101

102+
case 'sudoPrompt': {
103+
Logger.info('running tsrelay in sudo');
104+
try {
105+
await this.ts.initSudo();
106+
Logger.info(`re-applying ${m.operation}`);
107+
if (m.operation == 'add') {
108+
if (!m.params) {
109+
Logger.error('params cannot be null for an add operation');
110+
return;
111+
}
112+
await this.ts.serveAdd(m.params);
113+
} else if (m.operation == 'delete') {
114+
await this.ts.serveDelete();
115+
}
116+
} catch (e) {
117+
Logger.error(`error running sudo prompt: ${e}`);
118+
}
119+
break;
120+
}
121+
102122
default: {
103123
console.log('Unknown type for message', m);
104124
}

src/tailscale/cli.ts

Lines changed: 111 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ export class Tailscale {
2727
private _vscode: vscodeModule;
2828
private nonce?: string;
2929
public url?: string;
30+
private port?: string;
3031
public authkey?: string;
3132
private childProcess?: cp.ChildProcess;
33+
private notifyExit?: () => void;
3234

3335
constructor(vscode: vscodeModule) {
3436
this._vscode = vscode;
@@ -40,22 +42,9 @@ export class Tailscale {
4042
return ts;
4143
}
4244

43-
async init() {
45+
async init(port?: string, nonce?: string) {
4446
return new Promise<null>((resolve) => {
45-
let arch = process.arch;
46-
let platform: string = process.platform;
47-
// See:
48-
// https://goreleaser.com/customization/builds/#why-is-there-a-_v1-suffix-on-amd64-builds
49-
if (process.arch === 'x64') {
50-
arch = 'amd64_v1';
51-
}
52-
if (platform === 'win32') {
53-
platform = 'windows';
54-
}
55-
let binPath = path.join(
56-
__dirname,
57-
`../bin/vscode-tailscale_${platform}_${arch}/vscode-tailscale`
58-
);
47+
let binPath = this.tsrelayPath();
5948
let args = [];
6049
if (this._vscode.env.logLevel === LogLevel.Debug) {
6150
args.push('-v');
@@ -66,12 +55,22 @@ export class Tailscale {
6655
args = ['run', '.', ...args];
6756
cwd = path.join(cwd, '../tsrelay');
6857
}
69-
Logger.info(`path: ${binPath}`, LOG_COMPONENT);
58+
if (port) {
59+
args.push(`-port=${this.port}`);
60+
}
61+
if (nonce) {
62+
args.push(`-nonce=${this.nonce}`);
63+
}
64+
Logger.debug(`path: ${binPath}`, LOG_COMPONENT);
65+
Logger.debug(`args: ${args.join(' ')}`, LOG_COMPONENT);
7066

7167
this.childProcess = cp.spawn(binPath, args, { cwd: cwd });
7268

7369
this.childProcess.on('exit', (code) => {
7470
Logger.warn(`child process exited with code ${code}`, LOG_COMPONENT);
71+
if (this.notifyExit) {
72+
this.notifyExit();
73+
}
7574
});
7675

7776
this.childProcess.on('error', (err) => {
@@ -83,6 +82,7 @@ export class Tailscale {
8382
const details = JSON.parse(data.toString().trim()) as TSRelayDetails;
8483
this.url = details.address;
8584
this.nonce = details.nonce;
85+
this.port = details.port;
8686
this.authkey = Buffer.from(`${this.nonce}:`).toString('base64');
8787
Logger.info(`url: ${this.url}`, LOG_COMPONENT);
8888

@@ -100,40 +100,108 @@ export class Tailscale {
100100
throw new Error('childProcess.stdout is null');
101101
}
102102

103-
if (this.childProcess.stderr) {
104-
let buffer = '';
105-
this.childProcess.stderr.on('data', (data: Buffer) => {
106-
buffer += data.toString(); // Append the data to the buffer
103+
this.processStderr(this.childProcess);
104+
});
105+
}
107106

108-
const lines = buffer.split('\n'); // Split the buffer into lines
107+
processStderr(childProcess: cp.ChildProcess) {
108+
if (!childProcess.stderr) {
109+
Logger.error('childProcess.stderr is null', LOG_COMPONENT);
110+
throw new Error('childProcess.stderr is null');
111+
}
112+
let buffer = '';
113+
childProcess.stderr.on('data', (data: Buffer) => {
114+
buffer += data.toString(); // Append the data to the buffer
109115

110-
// Process all complete lines except the last one
111-
for (let i = 0; i < lines.length - 1; i++) {
112-
const line = lines[i].trim();
113-
if (line.length > 0) {
114-
Logger.info(line, LOG_COMPONENT);
115-
}
116-
}
116+
const lines = buffer.split('\n'); // Split the buffer into lines
117117

118-
buffer = lines[lines.length - 1];
119-
});
118+
// Process all complete lines except the last one
119+
for (let i = 0; i < lines.length - 1; i++) {
120+
const line = lines[i].trim();
121+
if (line.length > 0) {
122+
Logger.info(line, LOG_COMPONENT);
123+
}
124+
}
125+
126+
buffer = lines[lines.length - 1];
127+
});
128+
129+
childProcess.stderr.on('end', () => {
130+
// Process the remaining data in the buffer after the stream ends
131+
const line = buffer.trim();
132+
if (line.length > 0) {
133+
Logger.info(line, LOG_COMPONENT);
134+
}
135+
});
136+
}
120137

121-
this.childProcess.stderr.on('end', () => {
122-
// Process the remaining data in the buffer after the stream ends
123-
const line = buffer.trim();
124-
if (line.length > 0) {
125-
Logger.info(line, LOG_COMPONENT);
138+
async initSudo() {
139+
return new Promise<null>((resolve, err) => {
140+
const binPath = this.tsrelayPath();
141+
const args = [`-nonce=${this.nonce}`, `-port=${this.port}`];
142+
if (this._vscode.env.logLevel === LogLevel.Debug) {
143+
args.push('-v');
144+
}
145+
Logger.info(`path: ${binPath}`, LOG_COMPONENT);
146+
this.notifyExit = () => {
147+
Logger.info('starting sudo tsrelay');
148+
const childProcess = cp.spawn(`/usr/bin/pkexec`, [
149+
'--disable-internal-agent',
150+
binPath,
151+
...args,
152+
]);
153+
childProcess.on('exit', async (code) => {
154+
Logger.warn(`sudo child process exited with code ${code}`, LOG_COMPONENT);
155+
if (code === 0) {
156+
return;
157+
} else if (code === 126) {
158+
// authentication not successful
159+
this._vscode.window.showErrorMessage(
160+
'Creating a Funnel must be done by an administrator'
161+
);
162+
} else {
163+
this._vscode.window.showErrorMessage('Could not run authenticator, please check logs');
126164
}
165+
await this.init(this.port, this.nonce);
166+
err('unauthenticated');
127167
});
128-
} else {
129-
Logger.error('childProcess.stderr is null', LOG_COMPONENT);
130-
throw new Error('childProcess.stderr is null');
131-
}
168+
childProcess.on('error', (err) => {
169+
Logger.error(`sudo child process error ${err}`, LOG_COMPONENT);
170+
});
171+
childProcess.stdout.on('data', (data: Buffer) => {
172+
Logger.debug('received data from sudo');
173+
const details = JSON.parse(data.toString().trim()) as TSRelayDetails;
174+
if (this.url !== details.address) {
175+
Logger.error(`expected url to be ${this.url} but got ${details.address}`);
176+
return;
177+
}
178+
this.runPortDisco();
179+
Logger.debug('resolving');
180+
resolve(null);
181+
});
182+
this.processStderr(childProcess);
183+
};
184+
this.dispose();
132185
});
133186
}
134187

188+
tsrelayPath(): string {
189+
let arch = process.arch;
190+
let platform: string = process.platform;
191+
// See:
192+
// https://goreleaser.com/customization/builds/#why-is-there-a-_v1-suffix-on-amd64-builds
193+
if (process.arch === 'x64') {
194+
arch = 'amd64_v1';
195+
}
196+
if (platform === 'win32') {
197+
platform = 'windows';
198+
}
199+
return path.join(__dirname, `../bin/vscode-tailscale_${platform}_${arch}/vscode-tailscale`);
200+
}
201+
135202
dispose() {
136203
if (this.childProcess) {
204+
Logger.info('shutting down tsrelay');
137205
this.childProcess.kill();
138206
}
139207
}
@@ -227,7 +295,7 @@ export class Tailscale {
227295

228296
const ws = new WebSocket(`ws://${this.url.slice('http://'.length)}/portdisco`, {
229297
headers: {
230-
Authorization: 'Basic ' + Buffer.from(`${this.nonce}:`).toString('base64'),
298+
Authorization: 'Basic ' + this.authkey,
231299
},
232300
});
233301
ws.on('error', (e) => {
@@ -249,6 +317,9 @@ export class Tailscale {
249317
);
250318
});
251319
});
320+
ws.on('close', () => {
321+
Logger.info('websocket is closed');
322+
});
252323
ws.on('message', async (data) => {
253324
Logger.info('got message');
254325
const msg = JSON.parse(data.toString());

src/types.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@ export interface Handlers {
1919
Proxy: string;
2020
}
2121

22-
export interface ServeStatus {
22+
export interface ServeStatus extends WithErrors {
2323
ServeConfig?: ServeConfig;
2424
FunnelPorts?: number[];
2525
Services: {
2626
[port: number]: string;
2727
};
2828
BackendState: string;
2929
Self: PeerStatus;
30+
}
31+
32+
export interface WithErrors {
3033
Errors?: RelayError[];
3134
}
3235

3336
interface RelayError {
34-
Type: string;
37+
Type: 'FUNNEL_OFF' | 'HTTPS_OFF' | 'OFFLINE' | 'REQUIRES_SUDO' | 'NOT_RUNNING';
3538
}
3639

3740
interface PeerStatus {
@@ -118,7 +121,14 @@ export type Message =
118121
| ResetServe
119122
| SetFunnel
120123
| WriteToClipboard
121-
| OpenLink;
124+
| OpenLink
125+
| SudoPrompt;
126+
127+
interface SudoPrompt {
128+
type: 'sudoPrompt';
129+
operation: 'add' | 'delete';
130+
params?: ServeParams;
131+
}
122132

123133
/**
124134
* Messages sent from the extension to the webview.
@@ -156,4 +166,5 @@ export interface NewPortNotification {
156166
export interface TSRelayDetails {
157167
address: string;
158168
nonce: string;
169+
port: string;
159170
}

src/webviews/serve-panel/data.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,5 @@ export async function fetchWithUser(path: string, options: RequestInit = {}) {
2828
console.time(path);
2929
const res = await fetch(url + path, options);
3030
console.timeEnd(path);
31-
32-
if (options.method !== 'DELETE') {
33-
return res.json();
34-
}
31+
return res.json();
3532
}

src/webviews/serve-panel/simple-view.tsx

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { KB_FUNNEL_USE_CASES } from '../../utils/url';
88
import { useServe, useServeMutation, fetchWithUser } from './data';
99
import { Tooltip } from './components/tooltip';
1010
import { errorForType } from '../../tailscale/error';
11+
import { ServeParams, WithErrors } from '../../types';
1112

1213
export const SimpleView = () => {
1314
const { data, mutate, isLoading } = useServe();
@@ -60,32 +61,6 @@ export const SimpleView = () => {
6061
const textStyle = 'text-bannerForeground bg-bannerBackground';
6162
const textDisabledStyle = 'text-foreground bg-background';
6263
const hasServeTextStyle = persistedPort ? textStyle : textDisabledStyle;
63-
64-
// sorry Linux
65-
if (window.tailscale.platform === 'linux') {
66-
return (
67-
<div className="flex mt-2 bg-bannerBackground p-3 text-bannerForeground">
68-
<div className="pr-2 codicon codicon-terminal-linux !text-xl"></div>
69-
<div className=" text-2lg">
70-
<div className="font-bold ">Notice for Linux users</div>
71-
<div>
72-
We're working to resolve an issue preventing this extension from being used on Linux.
73-
<br />
74-
However, you can still use Funnel from the CLI.
75-
</div>
76-
<div className="mt-4">
77-
<VSCodeButton
78-
appearance="primary"
79-
onClick={() => vsCodeAPI.openLink('https://tailscale.com/kb/1223/tailscale-funnel')}
80-
>
81-
See CLI docs
82-
</VSCodeButton>
83-
</div>
84-
</div>
85-
</div>
86-
);
87-
}
88-
8964
return (
9065
<div>
9166
{data?.Errors?.map((error, index) => (
@@ -111,7 +86,6 @@ export const SimpleView = () => {
11186

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

218192
setIsDeleting(true);
219193

220-
await fetchWithUser('/serve', {
194+
const resp = (await fetchWithUser('/serve', {
221195
method: 'DELETE',
222196
body: '{}',
223-
});
197+
})) as WithErrors;
198+
if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') {
199+
vsCodeAPI.postMessage({
200+
type: 'sudoPrompt',
201+
operation: 'delete',
202+
});
203+
}
224204

225205
setIsDeleting(false);
226206
setPreviousPort(port);
@@ -240,12 +220,20 @@ export const SimpleView = () => {
240220
return;
241221
}
242222

243-
await trigger({
223+
const params: ServeParams = {
244224
protocol: 'https',
245225
port: 443,
246226
mountPoint: '/',
247227
source: `http://127.0.0.1:${port}`,
248228
funnel: true,
249-
});
229+
};
230+
const resp = (await trigger(params)) as WithErrors;
231+
if (resp.Errors?.length && resp.Errors[0].Type === 'REQUIRES_SUDO') {
232+
vsCodeAPI.postMessage({
233+
type: 'sudoPrompt',
234+
operation: 'add',
235+
params,
236+
});
237+
}
250238
}
251239
};

0 commit comments

Comments
 (0)