@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.
@@ -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
+ };
@@ -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