mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat: require unique --name parameter for remote servers
- Add mandatory --name parameter when using --hq-url - Ensure remote server names are unique across the HQ - Return 409 Conflict when duplicate name is registered - Track remotes by both ID and name in RemoteRegistry - Add /api/remotes endpoints for HQ mode - Improve error messages for registration failures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8bb22f291f
commit
862947b744
3 changed files with 89 additions and 6 deletions
|
|
@ -1,5 +1,4 @@
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as os from 'os';
|
|
||||||
|
|
||||||
export class HQClient {
|
export class HQClient {
|
||||||
private readonly hqUrl: string;
|
private readonly hqUrl: string;
|
||||||
|
|
@ -10,10 +9,10 @@ export class HQClient {
|
||||||
private readonly hqPassword: string;
|
private readonly hqPassword: string;
|
||||||
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(hqUrl: string, hqUsername: string, hqPassword: string) {
|
constructor(hqUrl: string, hqUsername: string, hqPassword: string, remoteName: string) {
|
||||||
this.hqUrl = hqUrl;
|
this.hqUrl = hqUrl;
|
||||||
this.remoteId = uuidv4();
|
this.remoteId = uuidv4();
|
||||||
this.remoteName = `${os.hostname()}-${process.pid}`;
|
this.remoteName = remoteName;
|
||||||
this.token = uuidv4();
|
this.token = uuidv4();
|
||||||
this.hqUsername = hqUsername;
|
this.hqUsername = hqUsername;
|
||||||
this.hqPassword = hqPassword;
|
this.hqPassword = hqPassword;
|
||||||
|
|
@ -36,11 +35,13 @@ export class HQClient {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Registration failed: ${response.statusText}`);
|
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
throw new Error(`Registration failed: ${errorBody.error || response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
|
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
|
||||||
console.log(`Remote ID: ${this.remoteId}`);
|
console.log(`Remote ID: ${this.remoteId}`);
|
||||||
|
console.log(`Remote name: ${this.remoteName}`);
|
||||||
console.log(`Token: ${this.token}`);
|
console.log(`Token: ${this.token}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to register with HQ:', error);
|
console.error('Failed to register with HQ:', error);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface RemoteServer {
|
||||||
|
|
||||||
export class RemoteRegistry {
|
export class RemoteRegistry {
|
||||||
private remotes: Map<string, RemoteServer> = new Map();
|
private remotes: Map<string, RemoteServer> = new Map();
|
||||||
|
private remotesByName: Map<string, RemoteServer> = new Map();
|
||||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||||
private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds
|
private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds
|
||||||
private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check
|
private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check
|
||||||
|
|
@ -20,6 +21,11 @@ export class RemoteRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
||||||
|
// Check if a remote with the same name already exists
|
||||||
|
if (this.remotesByName.has(remote.name)) {
|
||||||
|
throw new Error(`Remote with name '${remote.name}' is already registered`);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const registeredRemote: RemoteServer = {
|
const registeredRemote: RemoteServer = {
|
||||||
...remote,
|
...remote,
|
||||||
|
|
@ -29,6 +35,7 @@ export class RemoteRegistry {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.remotes.set(remote.id, registeredRemote);
|
this.remotes.set(remote.id, registeredRemote);
|
||||||
|
this.remotesByName.set(remote.name, registeredRemote);
|
||||||
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
||||||
|
|
||||||
// Immediately check health of new remote
|
// Immediately check health of new remote
|
||||||
|
|
@ -41,6 +48,7 @@ export class RemoteRegistry {
|
||||||
const remote = this.remotes.get(remoteId);
|
const remote = this.remotes.get(remoteId);
|
||||||
if (remote) {
|
if (remote) {
|
||||||
console.log(`Remote unregistered: ${remote.name} (${remoteId})`);
|
console.log(`Remote unregistered: ${remote.name} (${remoteId})`);
|
||||||
|
this.remotesByName.delete(remote.name);
|
||||||
return this.remotes.delete(remoteId);
|
return this.remotes.delete(remoteId);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
// Add JSON body parser middleware
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4020;
|
const PORT = process.env.PORT || 4020;
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
|
|
@ -33,6 +36,7 @@ let isHQMode = false;
|
||||||
let hqUrl: string | null = null;
|
let hqUrl: string | null = null;
|
||||||
let hqUsername: string | null = null;
|
let hqUsername: string | null = null;
|
||||||
let hqPassword: string | null = null;
|
let hqPassword: string | null = null;
|
||||||
|
let remoteName: string | null = null;
|
||||||
|
|
||||||
// Check for command line arguments
|
// Check for command line arguments
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
|
@ -53,6 +57,9 @@ for (let i = 0; i < args.length; i++) {
|
||||||
} else if (args[i] === '--hq-password' && i + 1 < args.length) {
|
} else if (args[i] === '--hq-password' && i + 1 < args.length) {
|
||||||
hqPassword = args[i + 1];
|
hqPassword = args[i + 1];
|
||||||
i++; // Skip the password value in next iteration
|
i++; // Skip the password value in next iteration
|
||||||
|
} else if (args[i] === '--name' && i + 1 < args.length) {
|
||||||
|
remoteName = args[i + 1];
|
||||||
|
i++; // Skip the name value in next iteration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +91,13 @@ if (hqUrl && (!hqUsername || !hqPassword)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate remote name is provided when registering with HQ
|
||||||
|
if (hqUrl && !remoteName) {
|
||||||
|
console.error(`${RED}ERROR: Remote name required when --hq-url is specified${RESET}`);
|
||||||
|
console.error('Use --name to specify a unique name for this remote server');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate HQ URL
|
// Validate HQ URL
|
||||||
if (hqUrl) {
|
if (hqUrl) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -153,9 +167,10 @@ if (isHQMode) {
|
||||||
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hqUrl && hqUsername && hqPassword) {
|
if (hqUrl && hqUsername && hqPassword && remoteName) {
|
||||||
hqClient = new HQClient(hqUrl, hqUsername, hqPassword);
|
hqClient = new HQClient(hqUrl, hqUsername, hqPassword, remoteName);
|
||||||
console.log(`${GREEN}Will register with HQ at: ${hqUrl}${RESET}`);
|
console.log(`${GREEN}Will register with HQ at: ${hqUrl}${RESET}`);
|
||||||
|
console.log(`${GREEN}Remote name: ${remoteName}${RESET}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure control directory exists
|
// Ensure control directory exists
|
||||||
|
|
@ -248,6 +263,65 @@ app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
// Hot reload functionality for development
|
// Hot reload functionality for development
|
||||||
const hotReloadClients = new Set<WebSocket>();
|
const hotReloadClients = new Set<WebSocket>();
|
||||||
|
|
||||||
|
// === HQ/REMOTE MANAGEMENT ===
|
||||||
|
|
||||||
|
// Register a remote server (HQ mode only)
|
||||||
|
app.post('/api/remotes/register', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, name, url, token } = req.body;
|
||||||
|
|
||||||
|
if (!id || !name || !url || !token) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: id, name, url, token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remote = remoteRegistry.register({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
sessionCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, remote });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('already registered')) {
|
||||||
|
return res.status(409).json({ error: error.message });
|
||||||
|
}
|
||||||
|
console.error('Failed to register remote:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to register remote' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unregister a remote server (HQ mode only)
|
||||||
|
app.delete('/api/remotes/:id', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const success = remoteRegistry.unregister(id);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Remote not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all remote servers (HQ mode only)
|
||||||
|
app.get('/api/remotes', (req, res) => {
|
||||||
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
return res.status(403).json({ error: 'This endpoint is only available in HQ mode' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotes = remoteRegistry.getAllRemotes();
|
||||||
|
res.json(remotes);
|
||||||
|
});
|
||||||
|
|
||||||
// === SESSION MANAGEMENT ===
|
// === SESSION MANAGEMENT ===
|
||||||
|
|
||||||
// List all sessions
|
// List all sessions
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue