Skip to content

Commit e73c2c4

Browse files
grichaclaude
andauthored
Add automatic Tailscale Serve integration (#27)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent edb2ca8 commit e73c2c4

7 files changed

Lines changed: 309 additions & 4 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Tailscale Integration
2+
3+
Perry automatically detects and integrates with [Tailscale](https://tailscale.com) to provide secure HTTPS access to your workspaces over your private network.
4+
5+
## How It Works
6+
7+
When you start the Perry agent, it checks if Tailscale is running on your machine. If Tailscale is detected with HTTPS enabled, Perry automatically starts [Tailscale Serve](https://tailscale.com/kb/1312/serve) to expose the agent over HTTPS using your Tailscale domain.
8+
9+
This gives you:
10+
- **Trusted HTTPS certificates** - No browser warnings, valid certificates from Let's Encrypt
11+
- **Private network access** - Access Perry from any device on your tailnet
12+
- **User identity** - Perry can identify who's making requests via Tailscale headers
13+
14+
## Setup
15+
16+
### 1. Install Tailscale
17+
18+
Follow the [Tailscale installation guide](https://tailscale.com/download) for your platform.
19+
20+
### 2. Enable HTTPS Certificates
21+
22+
Tailscale HTTPS must be enabled for your tailnet. This is typically enabled by default, but you can verify in your [Tailscale admin console](https://login.tailscale.com/admin/dns).
23+
24+
### 3. Set Operator Permissions (Required)
25+
26+
By default, Tailscale Serve requires root permissions. To allow Perry to use it without sudo, run:
27+
28+
```bash
29+
sudo tailscale set --operator=$USER
30+
```
31+
32+
This only needs to be done once per machine.
33+
34+
### 4. Start Perry
35+
36+
```bash
37+
perry agent run
38+
```
39+
40+
If Tailscale is properly configured, you'll see:
41+
42+
```
43+
[agent] Tailscale detected: your-machine.tail-scale.ts.net
44+
[agent] Tailscale Serve enabled
45+
[agent] Agent running at http://localhost:7391
46+
[agent] Tailscale HTTPS: https://your-machine.tail-scale.ts.net
47+
```
48+
49+
## Troubleshooting
50+
51+
### "Tailscale Serve requires operator permissions"
52+
53+
You'll see this message if Tailscale Serve can't start:
54+
55+
```
56+
[agent] Tailscale Serve requires operator permissions
57+
[agent] To enable: Run: sudo tailscale set --operator=$USER
58+
[agent] Continuing without HTTPS...
59+
```
60+
61+
**Fix:** Run `sudo tailscale set --operator=$USER` and restart the agent.
62+
63+
### "Tailscale HTTPS not enabled in tailnet"
64+
65+
Your tailnet doesn't have HTTPS certificates enabled. Check your [Tailscale admin DNS settings](https://login.tailscale.com/admin/dns) and ensure "HTTPS Certificates" is enabled.
66+
67+
### Tailscale Not Detected
68+
69+
If Perry doesn't detect Tailscale at all, verify Tailscale is running:
70+
71+
```bash
72+
tailscale status
73+
```
74+
75+
## Graceful Fallback
76+
77+
Perry always starts successfully regardless of Tailscale status:
78+
79+
| Scenario | Behavior |
80+
|----------|----------|
81+
| Tailscale not installed | Agent starts normally on localhost |
82+
| Tailscale running, HTTPS enabled, operator set | HTTPS via Tailscale Serve |
83+
| Tailscale running, HTTPS enabled, no operator | Logs fix instructions, falls back to localhost |
84+
| Tailscale running, HTTPS not enabled | Falls back to localhost |
85+
86+
## Security Considerations
87+
88+
When using Tailscale Serve:
89+
- Traffic is encrypted end-to-end within your tailnet
90+
- Perry can identify users via `Tailscale-User-*` headers
91+
- Access is limited to devices on your tailnet
92+
93+
Without Tailscale, Perry binds to localhost only by default. For remote access without Tailscale, consider using a reverse proxy with proper authentication.

docs/sidebars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const sidebars: SidebarsConfig = {
1414
'configuration/files',
1515
'configuration/github',
1616
'configuration/ai-agents',
17+
'configuration/tailscale',
1718
],
1819
},
1920
'cli',

src/agent/router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ const SSHKeyInfoSchema = z.object({
103103
hasPrivateKey: z.boolean(),
104104
});
105105

106+
export interface TailscaleInfo {
107+
running: boolean;
108+
dnsName?: string;
109+
serveActive: boolean;
110+
httpsUrl?: string;
111+
}
112+
106113
export interface RouterContext {
107114
workspaces: WorkspaceManager;
108115
config: { get: () => AgentConfig; set: (config: AgentConfig) => void };
@@ -112,6 +119,7 @@ export interface RouterContext {
112119
terminalServer: TerminalWebSocketServer;
113120
sessionsCache: SessionsCacheManager;
114121
modelCache: ModelCacheManager;
122+
tailscale?: TailscaleInfo;
115123
}
116124

117125
function mapErrorToORPC(err: unknown, defaultMessage: string): never {
@@ -264,6 +272,7 @@ export function createRouter(ctx: RouterContext) {
264272
workspacesCount: allWorkspaces.length,
265273
dockerVersion,
266274
terminalConnections: ctx.terminalServer.getConnectionCount(),
275+
tailscale: ctx.tailscale,
267276
};
268277
});
269278

src/agent/run.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { createRouter } from './router';
1414
import { serveStatic } from './static';
1515
import { SessionsCacheManager } from '../sessions/cache';
1616
import { ModelCacheManager } from '../models/cache';
17+
import {
18+
getTailscaleStatus,
19+
getTailscaleIdentity,
20+
startTailscaleServe,
21+
stopTailscaleServe,
22+
} from '../tailscale';
1723
import pkg from '../../package.json';
1824

1925
const startTime = Date.now();
@@ -23,7 +29,14 @@ function sendJson(res: ServerResponse, status: number, data: unknown): void {
2329
res.end(JSON.stringify(data));
2430
}
2531

26-
function createAgentServer(configDir: string, config: AgentConfig) {
32+
interface TailscaleInfo {
33+
running: boolean;
34+
dnsName?: string;
35+
serveActive: boolean;
36+
httpsUrl?: string;
37+
}
38+
39+
function createAgentServer(configDir: string, config: AgentConfig, tailscale?: TailscaleInfo) {
2740
let currentConfig = config;
2841
const workspaces = new WorkspaceManager(configDir, currentConfig);
2942
const sessionsCache = new SessionsCacheManager(configDir);
@@ -69,6 +82,7 @@ function createAgentServer(configDir: string, config: AgentConfig) {
6982
terminalServer,
7083
sessionsCache,
7184
modelCache,
85+
tailscale,
7286
});
7387

7488
const rpcHandler = new RPCHandler(router);
@@ -78,6 +92,8 @@ function createAgentServer(configDir: string, config: AgentConfig) {
7892
const method = req.method;
7993
const pathname = url.pathname;
8094

95+
const identity = getTailscaleIdentity(req);
96+
8197
res.setHeader('Access-Control-Allow-Origin', '*');
8298
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
8399
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -90,7 +106,11 @@ function createAgentServer(configDir: string, config: AgentConfig) {
90106

91107
try {
92108
if (pathname === '/health' && method === 'GET') {
93-
sendJson(res, 200, { status: 'ok', version: pkg.version });
109+
const response: Record<string, unknown> = { status: 'ok', version: pkg.version };
110+
if (identity) {
111+
response.user = identity.email;
112+
}
113+
sendJson(res, 200, response);
94114
return;
95115
}
96116

@@ -179,9 +199,43 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
179199
console.log(`[agent] Config directory: ${configDir}`);
180200
console.log(`[agent] Starting on port ${port}...`);
181201

202+
const tailscale = await getTailscaleStatus();
203+
let tailscaleServeActive = false;
204+
205+
if (tailscale.running && tailscale.dnsName) {
206+
console.log(`[agent] Tailscale detected: ${tailscale.dnsName}`);
207+
208+
if (!tailscale.httpsEnabled) {
209+
console.log(`[agent] Tailscale HTTPS not enabled in tailnet, skipping Serve`);
210+
} else {
211+
const result = await startTailscaleServe(port);
212+
if (result.success) {
213+
tailscaleServeActive = true;
214+
console.log(`[agent] Tailscale Serve enabled`);
215+
} else if (result.error === 'permission_denied') {
216+
console.log(`[agent] Tailscale Serve requires operator permissions`);
217+
console.log(`[agent] To enable: ${result.message}`);
218+
console.log(`[agent] Continuing without HTTPS...`);
219+
} else {
220+
console.log(`[agent] Tailscale Serve failed: ${result.message || 'unknown error'}`);
221+
}
222+
}
223+
}
224+
225+
const tailscaleInfo: TailscaleInfo | undefined =
226+
tailscale.running && tailscale.dnsName
227+
? {
228+
running: true,
229+
dnsName: tailscale.dnsName,
230+
serveActive: tailscaleServeActive,
231+
httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
232+
}
233+
: undefined;
234+
182235
const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(
183236
configDir,
184-
config
237+
config,
238+
tailscaleInfo
185239
);
186240

187241
server.on('error', async (err: NodeJS.ErrnoException) => {
@@ -201,6 +255,13 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
201255

202256
server.listen(port, '::', () => {
203257
console.log(`[agent] Agent running at http://localhost:${port}`);
258+
if (tailscale.running && tailscale.dnsName) {
259+
const shortName = tailscale.dnsName.split('.')[0];
260+
console.log(`[agent] Tailnet: http://${shortName}:${port}`);
261+
if (tailscaleServeActive) {
262+
console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
263+
}
264+
}
204265
console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
205266
console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
206267
console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
@@ -209,8 +270,12 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
209270
startEagerImagePull();
210271
});
211272

212-
const shutdown = () => {
273+
const shutdown = async () => {
213274
console.log('[agent] Shutting down...');
275+
if (tailscaleServeActive) {
276+
console.log('[agent] Stopping Tailscale Serve...');
277+
await stopTailscaleServe();
278+
}
214279
chatServer.close();
215280
opencodeServer.close();
216281
terminalServer.close();

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ program
224224
console.log(` Uptime: ${formatUptime(info.uptime)}`);
225225
console.log(` Workspaces: ${info.workspacesCount}`);
226226
console.log(` Docker: ${info.dockerVersion}`);
227+
if (info.tailscale?.running) {
228+
console.log(` Tailscale: ${info.tailscale.dnsName}`);
229+
if (info.tailscale.httpsUrl) {
230+
console.log(` HTTPS URL: ${info.tailscale.httpsUrl}`);
231+
}
232+
}
227233
}
228234
} catch (err) {
229235
handleError(err);

src/shared/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,20 @@ export interface HealthResponse {
8484
version: string;
8585
}
8686

87+
export interface TailscaleInfo {
88+
running: boolean;
89+
dnsName?: string;
90+
serveActive: boolean;
91+
httpsUrl?: string;
92+
}
93+
8794
export interface InfoResponse {
8895
hostname: string;
8996
uptime: number;
9097
workspacesCount: number;
9198
dockerVersion: string;
9299
terminalConnections: number;
100+
tailscale?: TailscaleInfo;
93101
}
94102

95103
export const DEFAULT_CONFIG_DIR =

0 commit comments

Comments
 (0)