Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions docs/docs/configuration/tailscale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Tailscale Integration

Perry automatically detects and integrates with [Tailscale](https://tailscale.com) to provide secure HTTPS access to your workspaces over your private network.

## How It Works

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.

This gives you:
- **Trusted HTTPS certificates** - No browser warnings, valid certificates from Let's Encrypt
- **Private network access** - Access Perry from any device on your tailnet
- **User identity** - Perry can identify who's making requests via Tailscale headers

## Setup

### 1. Install Tailscale

Follow the [Tailscale installation guide](https://tailscale.com/download) for your platform.

### 2. Enable HTTPS Certificates

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).

### 3. Set Operator Permissions (Required)

By default, Tailscale Serve requires root permissions. To allow Perry to use it without sudo, run:

```bash
sudo tailscale set --operator=$USER
```

This only needs to be done once per machine.

### 4. Start Perry

```bash
perry agent run
```

If Tailscale is properly configured, you'll see:

```
[agent] Tailscale detected: your-machine.tail-scale.ts.net
[agent] Tailscale Serve enabled
[agent] Agent running at http://localhost:7391
[agent] Tailscale HTTPS: https://your-machine.tail-scale.ts.net
```

## Troubleshooting

### "Tailscale Serve requires operator permissions"

You'll see this message if Tailscale Serve can't start:

```
[agent] Tailscale Serve requires operator permissions
[agent] To enable: Run: sudo tailscale set --operator=$USER
[agent] Continuing without HTTPS...
```

**Fix:** Run `sudo tailscale set --operator=$USER` and restart the agent.

### "Tailscale HTTPS not enabled in tailnet"

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.

### Tailscale Not Detected

If Perry doesn't detect Tailscale at all, verify Tailscale is running:

```bash
tailscale status
```

## Graceful Fallback

Perry always starts successfully regardless of Tailscale status:

| Scenario | Behavior |
|----------|----------|
| Tailscale not installed | Agent starts normally on localhost |
| Tailscale running, HTTPS enabled, operator set | HTTPS via Tailscale Serve |
| Tailscale running, HTTPS enabled, no operator | Logs fix instructions, falls back to localhost |
| Tailscale running, HTTPS not enabled | Falls back to localhost |

## Security Considerations

When using Tailscale Serve:
- Traffic is encrypted end-to-end within your tailnet
- Perry can identify users via `Tailscale-User-*` headers
- Access is limited to devices on your tailnet

Without Tailscale, Perry binds to localhost only by default. For remote access without Tailscale, consider using a reverse proxy with proper authentication.
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const sidebars: SidebarsConfig = {
'configuration/files',
'configuration/github',
'configuration/ai-agents',
'configuration/tailscale',
],
},
'cli',
Expand Down
9 changes: 9 additions & 0 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ const SSHKeyInfoSchema = z.object({
hasPrivateKey: z.boolean(),
});

export interface TailscaleInfo {
running: boolean;
dnsName?: string;
serveActive: boolean;
httpsUrl?: string;
}

export interface RouterContext {
workspaces: WorkspaceManager;
config: { get: () => AgentConfig; set: (config: AgentConfig) => void };
Expand All @@ -109,6 +116,7 @@ export interface RouterContext {
terminalServer: TerminalWebSocketServer;
sessionsCache: SessionsCacheManager;
modelCache: ModelCacheManager;
tailscale?: TailscaleInfo;
}

function mapErrorToORPC(err: unknown, defaultMessage: string): never {
Expand Down Expand Up @@ -261,6 +269,7 @@ export function createRouter(ctx: RouterContext) {
workspacesCount: allWorkspaces.length,
dockerVersion,
terminalConnections: ctx.terminalServer.getConnectionCount(),
tailscale: ctx.tailscale,
};
});

Expand Down
73 changes: 69 additions & 4 deletions src/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { createRouter } from './router';
import { serveStatic } from './static';
import { SessionsCacheManager } from '../sessions/cache';
import { ModelCacheManager } from '../models/cache';
import {
getTailscaleStatus,
getTailscaleIdentity,
startTailscaleServe,
stopTailscaleServe,
} from '../tailscale';
import pkg from '../../package.json';

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

function createAgentServer(configDir: string, config: AgentConfig) {
interface TailscaleInfo {
running: boolean;
dnsName?: string;
serveActive: boolean;
httpsUrl?: string;
}

function createAgentServer(configDir: string, config: AgentConfig, tailscale?: TailscaleInfo) {
let currentConfig = config;
const workspaces = new WorkspaceManager(configDir, currentConfig);
const sessionsCache = new SessionsCacheManager(configDir);
Expand Down Expand Up @@ -69,6 +82,7 @@ function createAgentServer(configDir: string, config: AgentConfig) {
terminalServer,
sessionsCache,
modelCache,
tailscale,
});

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

const identity = getTailscaleIdentity(req);

res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
Expand All @@ -90,7 +106,11 @@ function createAgentServer(configDir: string, config: AgentConfig) {

try {
if (pathname === '/health' && method === 'GET') {
sendJson(res, 200, { status: 'ok', version: pkg.version });
const response: Record<string, unknown> = { status: 'ok', version: pkg.version };
if (identity) {
response.user = identity.email;
}
sendJson(res, 200, response);
return;
}

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

const tailscale = await getTailscaleStatus();
let tailscaleServeActive = false;

if (tailscale.running && tailscale.dnsName) {
console.log(`[agent] Tailscale detected: ${tailscale.dnsName}`);

if (!tailscale.httpsEnabled) {
console.log(`[agent] Tailscale HTTPS not enabled in tailnet, skipping Serve`);
} else {
const result = await startTailscaleServe(port);
if (result.success) {
tailscaleServeActive = true;
console.log(`[agent] Tailscale Serve enabled`);
} else if (result.error === 'permission_denied') {
console.log(`[agent] Tailscale Serve requires operator permissions`);
console.log(`[agent] To enable: ${result.message}`);
console.log(`[agent] Continuing without HTTPS...`);
} else {
console.log(`[agent] Tailscale Serve failed: ${result.message || 'unknown error'}`);
}
}
}

const tailscaleInfo: TailscaleInfo | undefined =
tailscale.running && tailscale.dnsName
? {
running: true,
dnsName: tailscale.dnsName,
serveActive: tailscaleServeActive,
httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
}
: undefined;

const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(
configDir,
config
config,
tailscaleInfo
);

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

server.listen(port, '::', () => {
console.log(`[agent] Agent running at http://localhost:${port}`);
if (tailscale.running && tailscale.dnsName) {
const shortName = tailscale.dnsName.split('.')[0];
console.log(`[agent] Tailnet: http://${shortName}:${port}`);
if (tailscaleServeActive) {
console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
}
}
console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
Expand All @@ -209,8 +270,12 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
startEagerImagePull();
});

const shutdown = () => {
const shutdown = async () => {
console.log('[agent] Shutting down...');
if (tailscaleServeActive) {
console.log('[agent] Stopping Tailscale Serve...');
await stopTailscaleServe();
}
chatServer.close();
opencodeServer.close();
terminalServer.close();
Comment on lines 271 to 281

This comment was marked as outdated.

Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ program
console.log(` Uptime: ${formatUptime(info.uptime)}`);
console.log(` Workspaces: ${info.workspacesCount}`);
console.log(` Docker: ${info.dockerVersion}`);
if (info.tailscale?.running) {
console.log(` Tailscale: ${info.tailscale.dnsName}`);
if (info.tailscale.httpsUrl) {
console.log(` HTTPS URL: ${info.tailscale.httpsUrl}`);
}
}
}
} catch (err) {
handleError(err);
Expand Down
8 changes: 8 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,20 @@ export interface HealthResponse {
version: string;
}

export interface TailscaleInfo {
running: boolean;
dnsName?: string;
serveActive: boolean;
httpsUrl?: string;
}

export interface InfoResponse {
hostname: string;
uptime: number;
workspacesCount: number;
dockerVersion: string;
terminalConnections: number;
tailscale?: TailscaleInfo;
}

export const DEFAULT_CONFIG_DIR =
Expand Down
Loading