@ssdavidai/zo-tailscale 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/zo-tailscale.js +47 -0
- package/lib/cleanup.js +121 -0
- package/lib/setup.js +161 -0
- package/lib/status.js +46 -0
- package/lib/supervisor.js +107 -0
- package/lib/teardown.js +80 -0
- package/package.json +22 -0
- package/templates/start-tailscale.sh +29 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const command = process.argv[2];
|
|
5
|
+
|
|
6
|
+
const USAGE = `
|
|
7
|
+
zo-tailscale โ Tailscale sidecar for Zo workspaces
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
zo-tailscale setup Interactive setup: install sidecar, connect to tailnet
|
|
11
|
+
zo-tailscale status Show Tailscale connection status
|
|
12
|
+
zo-tailscale cleanup Remove offline/stale nodes via Tailscale API
|
|
13
|
+
zo-tailscale teardown Remove Tailscale sidecar from this workspace
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
switch (command) {
|
|
18
|
+
case 'setup': {
|
|
19
|
+
const { runSetup } = require('../lib/setup');
|
|
20
|
+
await runSetup();
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
case 'status': {
|
|
24
|
+
const { runStatus } = require('../lib/status');
|
|
25
|
+
await runStatus();
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
case 'cleanup': {
|
|
29
|
+
const { runCleanup } = require('../lib/cleanup');
|
|
30
|
+
await runCleanup();
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
case 'teardown': {
|
|
34
|
+
const { runTeardown } = require('../lib/teardown');
|
|
35
|
+
await runTeardown();
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
default:
|
|
39
|
+
console.log(USAGE);
|
|
40
|
+
process.exit(command ? 1 : 0);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((err) => {
|
|
45
|
+
console.error(`\nError: ${err.message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
package/lib/cleanup.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const { getSecret, setSecret, prompt } = require('./setup');
|
|
6
|
+
|
|
7
|
+
function apiRequest(method, path, apiKey, body) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const options = {
|
|
10
|
+
hostname: 'api.tailscale.com',
|
|
11
|
+
path,
|
|
12
|
+
method,
|
|
13
|
+
headers: {
|
|
14
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const req = https.request(options, (res) => {
|
|
20
|
+
let data = '';
|
|
21
|
+
res.on('data', (chunk) => (data += chunk));
|
|
22
|
+
res.on('end', () => {
|
|
23
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
24
|
+
resolve(data ? JSON.parse(data) : null);
|
|
25
|
+
} else {
|
|
26
|
+
reject(new Error(`API ${res.statusCode}: ${data}`));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
req.on('error', reject);
|
|
32
|
+
if (body) req.write(JSON.stringify(body));
|
|
33
|
+
req.end();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function runCleanup() {
|
|
38
|
+
console.log('\n๐งน Tailscale Node Cleanup\n');
|
|
39
|
+
|
|
40
|
+
// Get API key
|
|
41
|
+
let apiKey = getSecret('TAILSCALE_API_KEY');
|
|
42
|
+
|
|
43
|
+
if (apiKey) {
|
|
44
|
+
const reuse = await prompt(`Found existing API key (${apiKey.slice(0, 20)}...). Use it? [Y/n] `);
|
|
45
|
+
if (reuse.toLowerCase() === 'n') {
|
|
46
|
+
apiKey = await prompt('Enter your Tailscale API key (https://login.tailscale.com/admin/settings/keys): ');
|
|
47
|
+
setSecret('TAILSCALE_API_KEY', apiKey);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
apiKey = await prompt('Enter your Tailscale API key (https://login.tailscale.com/admin/settings/keys): ');
|
|
51
|
+
if (!apiKey) {
|
|
52
|
+
console.error('API key is required for cleanup.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
setSecret('TAILSCALE_API_KEY', apiKey);
|
|
56
|
+
console.log('โ API key saved to ~/.zo_secrets');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fetch devices
|
|
60
|
+
console.log('\nFetching devices...');
|
|
61
|
+
let devices;
|
|
62
|
+
try {
|
|
63
|
+
const result = await apiRequest('GET', '/api/v2/tailnet/-/devices', apiKey);
|
|
64
|
+
devices = result.devices || [];
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error('Failed to fetch devices:', e.message);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Find offline nodes
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const offline = devices.filter((d) => {
|
|
73
|
+
const lastSeen = new Date(d.lastSeen).getTime();
|
|
74
|
+
const offlineMs = now - lastSeen;
|
|
75
|
+
// Consider offline if not seen in 5+ minutes
|
|
76
|
+
return offlineMs > 5 * 60 * 1000;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (offline.length === 0) {
|
|
80
|
+
console.log(`All ${devices.length} devices are online. Nothing to clean up.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`\nFound ${offline.length} offline node(s):\n`);
|
|
85
|
+
offline.forEach((d, i) => {
|
|
86
|
+
const lastSeen = new Date(d.lastSeen);
|
|
87
|
+
const ago = humanizeAge(now - lastSeen.getTime());
|
|
88
|
+
console.log(` ${i + 1}. ${d.name.padEnd(35)} last seen: ${ago} ago`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const answer = await prompt(`\nDelete all ${offline.length} offline node(s)? [y/N] `);
|
|
92
|
+
if (answer.toLowerCase() !== 'y') {
|
|
93
|
+
console.log('Cancelled.');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Delete nodes
|
|
98
|
+
let deleted = 0;
|
|
99
|
+
for (const device of offline) {
|
|
100
|
+
try {
|
|
101
|
+
await apiRequest('DELETE', `/api/v2/device/${device.id}`, apiKey);
|
|
102
|
+
console.log(` โ Deleted ${device.name}`);
|
|
103
|
+
deleted++;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.log(` โ Failed to delete ${device.name}: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`\nโ
Deleted ${deleted}/${offline.length} offline node(s).`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function humanizeAge(ms) {
|
|
113
|
+
const minutes = Math.floor(ms / 60000);
|
|
114
|
+
if (minutes < 60) return `${minutes}m`;
|
|
115
|
+
const hours = Math.floor(minutes / 60);
|
|
116
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
117
|
+
const days = Math.floor(hours / 24);
|
|
118
|
+
return `${days}d ${hours % 24}h`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { runCleanup };
|
package/lib/setup.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const {
|
|
8
|
+
hasTailscaleProgram,
|
|
9
|
+
injectTailscaleProgram,
|
|
10
|
+
reloadSupervisor,
|
|
11
|
+
getTailscaleStatus,
|
|
12
|
+
} = require('./supervisor');
|
|
13
|
+
|
|
14
|
+
const SECRETS_FILE = '/root/.zo_secrets';
|
|
15
|
+
const STARTUP_SCRIPT = '/usr/local/bin/start-tailscale.sh';
|
|
16
|
+
const TEMPLATE = path.join(__dirname, '..', 'templates', 'start-tailscale.sh');
|
|
17
|
+
|
|
18
|
+
function prompt(question) {
|
|
19
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
rl.close();
|
|
23
|
+
resolve(answer.trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function checkTailscaleBinary() {
|
|
29
|
+
try {
|
|
30
|
+
execSync('which tailscaled', { encoding: 'utf8' });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readSecrets() {
|
|
38
|
+
if (!fs.existsSync(SECRETS_FILE)) return '';
|
|
39
|
+
return fs.readFileSync(SECRETS_FILE, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setSecret(name, value) {
|
|
43
|
+
let content = readSecrets();
|
|
44
|
+
const line = `export ${name}="${value}"`;
|
|
45
|
+
const regex = new RegExp(`^export ${name}=.*$`, 'm');
|
|
46
|
+
|
|
47
|
+
if (regex.test(content)) {
|
|
48
|
+
content = content.replace(regex, line);
|
|
49
|
+
} else {
|
|
50
|
+
content = content.trimEnd() + '\n' + line + '\n';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fs.writeFileSync(SECRETS_FILE, content);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getSecret(name) {
|
|
57
|
+
const content = readSecrets();
|
|
58
|
+
const match = content.match(new RegExp(`^export ${name}="?([^"\\n]*)"?`, 'm'));
|
|
59
|
+
return match ? match[1] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeStartupScript(hostname) {
|
|
63
|
+
let template = fs.readFileSync(TEMPLATE, 'utf8');
|
|
64
|
+
template = template.replace('__HOSTNAME__', hostname);
|
|
65
|
+
fs.writeFileSync(STARTUP_SCRIPT, template, { mode: 0o755 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function waitForTailscale(maxWait = 30) {
|
|
69
|
+
for (let i = 0; i < maxWait; i++) {
|
|
70
|
+
try {
|
|
71
|
+
const result = execSync('tailscale status --json 2>/dev/null', {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
timeout: 5000,
|
|
74
|
+
});
|
|
75
|
+
const status = JSON.parse(result);
|
|
76
|
+
if (status.BackendState === 'Running') return status;
|
|
77
|
+
} catch {
|
|
78
|
+
// not ready yet
|
|
79
|
+
}
|
|
80
|
+
execSync('sleep 1');
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function runSetup() {
|
|
86
|
+
console.log('\n๐ง zo-tailscale setup\n');
|
|
87
|
+
|
|
88
|
+
// Step 1: Check binary
|
|
89
|
+
if (!checkTailscaleBinary()) {
|
|
90
|
+
console.error('tailscaled binary not found. Is Tailscale installed on this workspace?');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
console.log('โ tailscaled binary found');
|
|
94
|
+
|
|
95
|
+
// Step 2: Auth key
|
|
96
|
+
const existingKey = getSecret('TAILSCALE_AUTHKEY');
|
|
97
|
+
let authKey;
|
|
98
|
+
|
|
99
|
+
if (existingKey) {
|
|
100
|
+
const reuse = await prompt(`Found existing auth key (${existingKey.slice(0, 20)}...). Use it? [Y/n] `);
|
|
101
|
+
if (reuse.toLowerCase() === 'n') {
|
|
102
|
+
authKey = await prompt('Enter your Tailscale auth key (https://login.tailscale.com/admin/settings/keys): ');
|
|
103
|
+
} else {
|
|
104
|
+
authKey = existingKey;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
authKey = await prompt('Enter your Tailscale auth key (https://login.tailscale.com/admin/settings/keys): ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!authKey || !authKey.startsWith('tskey-auth-')) {
|
|
111
|
+
console.error('Invalid auth key. It should start with "tskey-auth-".');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Step 3: Hostname
|
|
116
|
+
const hostname = (await prompt('Hostname for this node [zo-workspace]: ')) || 'zo-workspace';
|
|
117
|
+
|
|
118
|
+
// Step 4: Save auth key
|
|
119
|
+
console.log('\nConfiguring...');
|
|
120
|
+
setSecret('TAILSCALE_AUTHKEY', authKey);
|
|
121
|
+
console.log('โ Auth key saved to ~/.zo_secrets');
|
|
122
|
+
|
|
123
|
+
// Step 5: Write startup script
|
|
124
|
+
writeStartupScript(hostname);
|
|
125
|
+
console.log('โ Startup script written to ' + STARTUP_SCRIPT);
|
|
126
|
+
|
|
127
|
+
// Step 6: Supervisor config
|
|
128
|
+
if (hasTailscaleProgram()) {
|
|
129
|
+
console.log('โ Supervisor config already has tailscale program');
|
|
130
|
+
} else {
|
|
131
|
+
injectTailscaleProgram();
|
|
132
|
+
console.log('โ Injected tailscale program into supervisor config');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Step 7: Reload supervisor
|
|
136
|
+
console.log('\nStarting Tailscale...');
|
|
137
|
+
try {
|
|
138
|
+
reloadSupervisor();
|
|
139
|
+
console.log('โ Supervisor reloaded');
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error('Warning: Could not reload supervisor:', e.message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Step 8: Wait for connection
|
|
145
|
+
console.log('Waiting for Tailscale to connect...');
|
|
146
|
+
const status = waitForTailscale();
|
|
147
|
+
|
|
148
|
+
if (status) {
|
|
149
|
+
const self = status.Self;
|
|
150
|
+
console.log(`\nโ
Tailscale is connected!`);
|
|
151
|
+
console.log(` Hostname: ${self.HostName}`);
|
|
152
|
+
console.log(` IP: ${self.TailscaleIPs ? self.TailscaleIPs[0] : 'N/A'}`);
|
|
153
|
+
console.log(` State: ${status.BackendState}`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log('\nโ ๏ธ Tailscale did not connect within 30s.');
|
|
156
|
+
console.log(' Check logs: cat /dev/shm/tailscale_err.log');
|
|
157
|
+
console.log(' Supervisor: ' + getTailscaleStatus());
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { runSetup, getSecret, setSecret, prompt };
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { getTailscaleStatus } = require('./supervisor');
|
|
5
|
+
|
|
6
|
+
function runStatus() {
|
|
7
|
+
console.log('\n๐ก Tailscale Status\n');
|
|
8
|
+
|
|
9
|
+
// Supervisor status
|
|
10
|
+
const supervisorStatus = getTailscaleStatus();
|
|
11
|
+
console.log(`Supervisor: ${supervisorStatus}`);
|
|
12
|
+
|
|
13
|
+
// Tailscale status
|
|
14
|
+
try {
|
|
15
|
+
const jsonStr = execSync('tailscale status --json 2>/dev/null', {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
timeout: 5000,
|
|
18
|
+
});
|
|
19
|
+
const status = JSON.parse(jsonStr);
|
|
20
|
+
const self = status.Self;
|
|
21
|
+
|
|
22
|
+
console.log(`\nBackend: ${status.BackendState}`);
|
|
23
|
+
console.log(`Hostname: ${self.HostName}`);
|
|
24
|
+
console.log(`Tailnet: ${status.MagicDNSSuffix || 'N/A'}`);
|
|
25
|
+
|
|
26
|
+
if (self.TailscaleIPs) {
|
|
27
|
+
console.log(`IPs: ${self.TailscaleIPs.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Show peers
|
|
31
|
+
const peers = Object.values(status.Peer || {});
|
|
32
|
+
if (peers.length > 0) {
|
|
33
|
+
console.log(`\nPeers (${peers.length}):`);
|
|
34
|
+
for (const peer of peers) {
|
|
35
|
+
const online = peer.Online ? 'โ' : 'โ';
|
|
36
|
+
const ips = peer.TailscaleIPs ? peer.TailscaleIPs[0] : '';
|
|
37
|
+
console.log(` ${online} ${peer.HostName.padEnd(30)} ${ips.padEnd(18)} ${peer.OS || ''}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
console.log('\nTailscale is not running or not connected.');
|
|
42
|
+
console.log('Run "zo-tailscale setup" to configure.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { runStatus };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
const SUPERVISOR_CONF = '/etc/zo/supervisord-user.conf';
|
|
7
|
+
const SUPERVISOR_URL = 'http://127.0.0.1:29011';
|
|
8
|
+
const PROGRAM_NAME = 'tailscale';
|
|
9
|
+
|
|
10
|
+
const TAILSCALE_BLOCK = `
|
|
11
|
+
[program:tailscale]
|
|
12
|
+
command=/usr/local/bin/start-tailscale.sh
|
|
13
|
+
directory=/root
|
|
14
|
+
autostart=true
|
|
15
|
+
autorestart=true
|
|
16
|
+
startretries=10
|
|
17
|
+
startsecs=5
|
|
18
|
+
stdout_logfile=/dev/shm/tailscale.log
|
|
19
|
+
stderr_logfile=/dev/shm/tailscale_err.log
|
|
20
|
+
stdout_logfile_maxbytes=10MB
|
|
21
|
+
stdout_logfile_backups=3
|
|
22
|
+
stopsignal=TERM
|
|
23
|
+
stopasgroup=true
|
|
24
|
+
killasgroup=true
|
|
25
|
+
stopwaitsecs=10
|
|
26
|
+
`.trimStart();
|
|
27
|
+
|
|
28
|
+
function hasTailscaleProgram() {
|
|
29
|
+
if (!fs.existsSync(SUPERVISOR_CONF)) return false;
|
|
30
|
+
const content = fs.readFileSync(SUPERVISOR_CONF, 'utf8');
|
|
31
|
+
return content.includes('[program:tailscale]');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function injectTailscaleProgram() {
|
|
35
|
+
if (hasTailscaleProgram()) {
|
|
36
|
+
return false; // already present
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let content = fs.readFileSync(SUPERVISOR_CONF, 'utf8');
|
|
40
|
+
|
|
41
|
+
// Find the first [program:*] section and insert before it
|
|
42
|
+
const programMatch = content.match(/^(\[program:)/m);
|
|
43
|
+
if (programMatch) {
|
|
44
|
+
const idx = content.indexOf(programMatch[0]);
|
|
45
|
+
content = content.slice(0, idx) + TAILSCALE_BLOCK + '\n' + content.slice(idx);
|
|
46
|
+
} else {
|
|
47
|
+
// No programs yet, append
|
|
48
|
+
content = content.trimEnd() + '\n\n' + TAILSCALE_BLOCK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fs.writeFileSync(SUPERVISOR_CONF, content);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removeTailscaleProgram() {
|
|
56
|
+
if (!hasTailscaleProgram()) return false;
|
|
57
|
+
|
|
58
|
+
let content = fs.readFileSync(SUPERVISOR_CONF, 'utf8');
|
|
59
|
+
|
|
60
|
+
// Remove the [program:tailscale] block (from header to next section or EOF)
|
|
61
|
+
const regex = /\[program:tailscale\][^\[]*(?=\[|$)/s;
|
|
62
|
+
content = content.replace(regex, '');
|
|
63
|
+
|
|
64
|
+
// Clean up extra blank lines
|
|
65
|
+
content = content.replace(/\n{3,}/g, '\n\n');
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(SUPERVISOR_CONF, content);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function supervisorctl(cmd) {
|
|
72
|
+
return execSync(`supervisorctl -s ${SUPERVISOR_URL} ${cmd}`, {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
timeout: 15000,
|
|
75
|
+
}).trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reloadSupervisor() {
|
|
79
|
+
supervisorctl('reread');
|
|
80
|
+
return supervisorctl('update');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getTailscaleStatus() {
|
|
84
|
+
try {
|
|
85
|
+
return supervisorctl(`status ${PROGRAM_NAME}`);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return e.stdout || e.message;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stopTailscale() {
|
|
92
|
+
try {
|
|
93
|
+
return supervisorctl(`stop ${PROGRAM_NAME}`);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return e.stdout || e.message;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
SUPERVISOR_CONF,
|
|
101
|
+
hasTailscaleProgram,
|
|
102
|
+
injectTailscaleProgram,
|
|
103
|
+
removeTailscaleProgram,
|
|
104
|
+
reloadSupervisor,
|
|
105
|
+
getTailscaleStatus,
|
|
106
|
+
stopTailscale,
|
|
107
|
+
};
|
package/lib/teardown.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const readline = require('readline');
|
|
5
|
+
const {
|
|
6
|
+
hasTailscaleProgram,
|
|
7
|
+
removeTailscaleProgram,
|
|
8
|
+
reloadSupervisor,
|
|
9
|
+
stopTailscale,
|
|
10
|
+
} = require('./supervisor');
|
|
11
|
+
|
|
12
|
+
const SECRETS_FILE = '/root/.zo_secrets';
|
|
13
|
+
const STARTUP_SCRIPT = '/usr/local/bin/start-tailscale.sh';
|
|
14
|
+
|
|
15
|
+
function prompt(question) {
|
|
16
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
rl.question(question, (answer) => {
|
|
19
|
+
rl.close();
|
|
20
|
+
resolve(answer.trim());
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function removeSecret(name) {
|
|
26
|
+
if (!fs.existsSync(SECRETS_FILE)) return;
|
|
27
|
+
let content = fs.readFileSync(SECRETS_FILE, 'utf8');
|
|
28
|
+
const regex = new RegExp(`^export ${name}=.*\\n?`, 'gm');
|
|
29
|
+
content = content.replace(regex, '');
|
|
30
|
+
fs.writeFileSync(SECRETS_FILE, content);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runTeardown() {
|
|
34
|
+
console.log('\n๐๏ธ zo-tailscale teardown\n');
|
|
35
|
+
|
|
36
|
+
const answer = await prompt('This will remove Tailscale sidecar from this workspace. Continue? [y/N] ');
|
|
37
|
+
if (answer.toLowerCase() !== 'y') {
|
|
38
|
+
console.log('Cancelled.');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Stop tailscale via supervisor
|
|
43
|
+
console.log('\nStopping Tailscale...');
|
|
44
|
+
try {
|
|
45
|
+
stopTailscale();
|
|
46
|
+
console.log('โ Tailscale stopped');
|
|
47
|
+
} catch {
|
|
48
|
+
console.log(' (was not running)');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Remove supervisor config
|
|
52
|
+
if (hasTailscaleProgram()) {
|
|
53
|
+
removeTailscaleProgram();
|
|
54
|
+
console.log('โ Removed tailscale from supervisor config');
|
|
55
|
+
try {
|
|
56
|
+
reloadSupervisor();
|
|
57
|
+
console.log('โ Supervisor reloaded');
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.log(' Warning: Could not reload supervisor:', e.message);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
console.log(' Supervisor config already clean');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Remove startup script
|
|
66
|
+
if (fs.existsSync(STARTUP_SCRIPT)) {
|
|
67
|
+
fs.unlinkSync(STARTUP_SCRIPT);
|
|
68
|
+
console.log('โ Removed ' + STARTUP_SCRIPT);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Remove secrets
|
|
72
|
+
removeSecret('TAILSCALE_AUTHKEY');
|
|
73
|
+
removeSecret('TAILSCALE_API_KEY');
|
|
74
|
+
console.log('โ Removed Tailscale keys from ~/.zo_secrets');
|
|
75
|
+
|
|
76
|
+
console.log('\nโ
Tailscale has been removed from this workspace.');
|
|
77
|
+
console.log(' Note: The tailscale/tailscaled binaries were not removed.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { runTeardown };
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ssdavidai/zo-tailscale",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Set up Tailscale on your Zo workspace with auto-restart on session reboot",
|
|
5
|
+
"bin": {
|
|
6
|
+
"zo-tailscale": "./bin/zo-tailscale.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"lib/",
|
|
11
|
+
"templates/"
|
|
12
|
+
],
|
|
13
|
+
"keywords": ["zo", "tailscale", "vpn", "sidecar"],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/ssdavidai/zo-tailscale.git"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tailscale sidecar startup script
|
|
3
|
+
# Runs tailscaled in userspace networking mode and authenticates
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
source /root/.zo_secrets
|
|
8
|
+
# TAILSCALE_AUTHKEY is loaded from ~/.zo_secrets
|
|
9
|
+
TAILSCALE_STATE_DIR="/var/lib/tailscale"
|
|
10
|
+
|
|
11
|
+
mkdir -p "$TAILSCALE_STATE_DIR"
|
|
12
|
+
|
|
13
|
+
# Start tailscaled in userspace networking mode (no iptables/netfilter needed)
|
|
14
|
+
tailscaled --tun=userspace-networking --state="$TAILSCALE_STATE_DIR/tailscaled.state" --socket=/var/run/tailscale/tailscaled.sock &
|
|
15
|
+
DAEMON_PID=$!
|
|
16
|
+
|
|
17
|
+
# Wait for the socket to be ready
|
|
18
|
+
for i in $(seq 1 30); do
|
|
19
|
+
if [ -S /var/run/tailscale/tailscaled.sock ]; then
|
|
20
|
+
break
|
|
21
|
+
fi
|
|
22
|
+
sleep 1
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
# Authenticate and connect
|
|
26
|
+
tailscale up --authkey="$TAILSCALE_AUTHKEY" --hostname="__HOSTNAME__" --accept-routes
|
|
27
|
+
|
|
28
|
+
# Keep the script running as long as tailscaled is alive
|
|
29
|
+
wait $DAEMON_PID
|