mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat: implement proper authentication with username/password support
- Add VIBETUNNEL_USERNAME/PASSWORD env vars and --username/--password CLI args - Separate local auth from HQ registration auth (--hq-username/--hq-password) - Validate that both username and password are provided or neither - Update authentication to use configured username instead of hardcoded 'admin' - Fix type errors and lint issues BREAKING CHANGE: Authentication now requires both username and password to be specified together 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
72d4bfda5c
commit
ccea6fba02
3 changed files with 102 additions and 63 deletions
|
|
@ -6,15 +6,17 @@ export class HQClient {
|
||||||
private readonly remoteId: string;
|
private readonly remoteId: string;
|
||||||
private readonly remoteName: string;
|
private readonly remoteName: string;
|
||||||
private readonly token: string;
|
private readonly token: string;
|
||||||
private readonly password: string;
|
private readonly hqUsername: string;
|
||||||
|
private readonly hqPassword: string;
|
||||||
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(hqUrl: string, password: string) {
|
constructor(hqUrl: string, hqUsername: string, hqPassword: string) {
|
||||||
this.hqUrl = hqUrl;
|
this.hqUrl = hqUrl;
|
||||||
this.remoteId = uuidv4();
|
this.remoteId = uuidv4();
|
||||||
this.remoteName = `${os.hostname()}-${process.pid}`;
|
this.remoteName = `${os.hostname()}-${process.pid}`;
|
||||||
this.token = uuidv4();
|
this.token = uuidv4();
|
||||||
this.password = password;
|
this.hqUsername = hqUsername;
|
||||||
|
this.hqPassword = hqPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(): Promise<void> {
|
async register(): Promise<void> {
|
||||||
|
|
@ -23,7 +25,7 @@ export class HQClient {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${this.hqUsername}:${this.hqPassword}`).toString('base64')}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: this.remoteId,
|
id: this.remoteId,
|
||||||
|
|
@ -56,7 +58,7 @@ export class HQClient {
|
||||||
fetch(`${this.hqUrl}/api/remotes/${this.remoteId}`, {
|
fetch(`${this.hqUrl}/api/remotes/${this.remoteId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${this.hqUsername}:${this.hqPassword}`).toString('base64')}`,
|
||||||
},
|
},
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Ignore errors during shutdown
|
// Ignore errors during shutdown
|
||||||
|
|
|
||||||
|
|
@ -19,56 +19,91 @@ const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4020;
|
const PORT = process.env.PORT || 4020;
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let basicAuthPassword: string | null = null;
|
|
||||||
let isHQMode = false;
|
|
||||||
let joinHQUrl: string | null = null;
|
|
||||||
|
|
||||||
// Check for command line arguments
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
if (args[i] === '--password' && i + 1 < args.length) {
|
|
||||||
basicAuthPassword = args[i + 1];
|
|
||||||
i++; // Skip the password value in next iteration
|
|
||||||
} else if (args[i] === '--hq') {
|
|
||||||
isHQMode = true;
|
|
||||||
} else if (args[i] === '--join-hq' && i + 1 < args.length) {
|
|
||||||
joinHQUrl = args[i + 1];
|
|
||||||
i++; // Skip the URL value in next iteration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to environment variable if no --password argument
|
|
||||||
if (!basicAuthPassword && process.env.VIBETUNNEL_PASSWORD) {
|
|
||||||
basicAuthPassword = process.env.VIBETUNNEL_PASSWORD;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate join-hq URL
|
|
||||||
if (joinHQUrl) {
|
|
||||||
try {
|
|
||||||
const url = new URL(joinHQUrl);
|
|
||||||
if (url.protocol !== 'https:') {
|
|
||||||
console.error(`${RED}ERROR: --join-hq URL must use HTTPS protocol${RESET}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.error(`${RED}ERROR: Invalid --join-hq URL: ${joinHQUrl}${RESET}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
const RED = '\x1b[31m';
|
const RED = '\x1b[31m';
|
||||||
const YELLOW = '\x1b[33m';
|
const YELLOW = '\x1b[33m';
|
||||||
const GREEN = '\x1b[32m';
|
const GREEN = '\x1b[32m';
|
||||||
const RESET = '\x1b[0m';
|
const RESET = '\x1b[0m';
|
||||||
|
|
||||||
if (basicAuthPassword) {
|
// Parse command line arguments
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let basicAuthUsername: string | null = null;
|
||||||
|
let basicAuthPassword: string | null = null;
|
||||||
|
let isHQMode = false;
|
||||||
|
let hqUrl: string | null = null;
|
||||||
|
let hqUsername: string | null = null;
|
||||||
|
let hqPassword: string | null = null;
|
||||||
|
|
||||||
|
// Check for command line arguments
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--username' && i + 1 < args.length) {
|
||||||
|
basicAuthUsername = args[i + 1];
|
||||||
|
i++; // Skip the username value in next iteration
|
||||||
|
} else if (args[i] === '--password' && i + 1 < args.length) {
|
||||||
|
basicAuthPassword = args[i + 1];
|
||||||
|
i++; // Skip the password value in next iteration
|
||||||
|
} else if (args[i] === '--hq') {
|
||||||
|
isHQMode = true;
|
||||||
|
} else if (args[i] === '--hq-url' && i + 1 < args.length) {
|
||||||
|
hqUrl = args[i + 1];
|
||||||
|
i++; // Skip the URL value in next iteration
|
||||||
|
} else if (args[i] === '--hq-username' && i + 1 < args.length) {
|
||||||
|
hqUsername = args[i + 1];
|
||||||
|
i++; // Skip the username value in next iteration
|
||||||
|
} else if (args[i] === '--hq-password' && i + 1 < args.length) {
|
||||||
|
hqPassword = args[i + 1];
|
||||||
|
i++; // Skip the password value in next iteration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variables for local auth
|
||||||
|
if (!basicAuthUsername && process.env.VIBETUNNEL_USERNAME) {
|
||||||
|
basicAuthUsername = process.env.VIBETUNNEL_USERNAME;
|
||||||
|
}
|
||||||
|
if (!basicAuthPassword && process.env.VIBETUNNEL_PASSWORD) {
|
||||||
|
basicAuthPassword = process.env.VIBETUNNEL_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate local auth configuration
|
||||||
|
if ((basicAuthUsername && !basicAuthPassword) || (!basicAuthUsername && basicAuthPassword)) {
|
||||||
|
console.error(
|
||||||
|
`${RED}ERROR: Both username and password must be provided for authentication${RESET}`
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
'Use --username and --password, or set both VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate HQ registration configuration
|
||||||
|
if (hqUrl && (!hqUsername || !hqPassword)) {
|
||||||
|
console.error(
|
||||||
|
`${RED}ERROR: HQ username and password required when --hq-url is specified${RESET}`
|
||||||
|
);
|
||||||
|
console.error('Use --hq-username and --hq-password with --hq-url');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate HQ URL
|
||||||
|
if (hqUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(hqUrl);
|
||||||
|
if (url.protocol !== 'https:') {
|
||||||
|
console.error(`${RED}ERROR: --hq-url must use HTTPS protocol${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error(`${RED}ERROR: Invalid --hq-url: ${hqUrl}${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basicAuthUsername && basicAuthPassword) {
|
||||||
console.log(`${GREEN}Basic authentication enabled${RESET}`);
|
console.log(`${GREEN}Basic authentication enabled${RESET}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${RED}WARNING: No authentication configured!${RESET}`);
|
console.log(`${RED}WARNING: No authentication configured!${RESET}`);
|
||||||
console.log(
|
console.log(
|
||||||
`${YELLOW}Set VIBETUNNEL_PASSWORD environment variable or use --password flag to enable authentication.${RESET}`
|
`${YELLOW}Set VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD or use --username and --password flags.${RESET}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,12 +153,9 @@ if (isHQMode) {
|
||||||
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (joinHQUrl && basicAuthPassword) {
|
if (hqUrl && hqUsername && hqPassword) {
|
||||||
hqClient = new HQClient(joinHQUrl, basicAuthPassword);
|
hqClient = new HQClient(hqUrl, hqUsername, hqPassword);
|
||||||
console.log(`${GREEN}Will register with HQ at: ${joinHQUrl}${RESET}`);
|
console.log(`${GREEN}Will register with HQ at: ${hqUrl}${RESET}`);
|
||||||
} else if (joinHQUrl && !basicAuthPassword) {
|
|
||||||
console.error(`${RED}ERROR: --join-hq requires --password to be set${RESET}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure control directory exists
|
// Ensure control directory exists
|
||||||
|
|
@ -164,7 +196,7 @@ const authMiddleware = (
|
||||||
next: express.NextFunction
|
next: express.NextFunction
|
||||||
) => {
|
) => {
|
||||||
// Skip auth if not configured
|
// Skip auth if not configured
|
||||||
if (!basicAuthPassword && !hqClient) {
|
if (!(basicAuthUsername && basicAuthPassword) && !hqClient) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,12 +221,12 @@ const authMiddleware = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Basic Auth
|
// Check Basic Auth
|
||||||
if (authHeader.startsWith('Basic ') && basicAuthPassword) {
|
if (authHeader.startsWith('Basic ') && basicAuthUsername && basicAuthPassword) {
|
||||||
const base64Credentials = authHeader.slice(6);
|
const base64Credentials = authHeader.slice(6);
|
||||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||||
const [username, password] = credentials.split(':');
|
const [username, password] = credentials.split(':');
|
||||||
|
|
||||||
if (username === 'admin' && password === basicAuthPassword) {
|
if (username === basicAuthUsername && password === basicAuthPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +236,7 @@ const authMiddleware = (
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply auth middleware if authentication is configured
|
// Apply auth middleware if authentication is configured
|
||||||
if (basicAuthPassword || hqClient) {
|
if ((basicAuthUsername && basicAuthPassword) || hqClient) {
|
||||||
app.use(authMiddleware);
|
app.use(authMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1150,7 +1182,7 @@ function sendBinaryBuffer(ws: WebSocket, sessionId: string, snapshot: BufferSnap
|
||||||
// WebSocket connections
|
// WebSocket connections
|
||||||
wss.on('connection', (ws, req) => {
|
wss.on('connection', (ws, req) => {
|
||||||
// Check authentication for WebSocket connections
|
// Check authentication for WebSocket connections
|
||||||
if (basicAuthPassword || hqClient) {
|
if ((basicAuthUsername && basicAuthPassword) || hqClient) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
|
|
@ -1169,12 +1201,17 @@ wss.on('connection', (ws, req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Basic Auth
|
// Check Basic Auth
|
||||||
if (!authenticated && authHeader.startsWith('Basic ') && basicAuthPassword) {
|
if (
|
||||||
|
!authenticated &&
|
||||||
|
authHeader.startsWith('Basic ') &&
|
||||||
|
basicAuthUsername &&
|
||||||
|
basicAuthPassword
|
||||||
|
) {
|
||||||
const base64Credentials = authHeader.slice(6);
|
const base64Credentials = authHeader.slice(6);
|
||||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||||
const [username, password] = credentials.split(':');
|
const [username, password] = credentials.split(':');
|
||||||
|
|
||||||
if (username === 'admin' && password === basicAuthPassword) {
|
if (username === basicAuthUsername && password === basicAuthPassword) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1228,14 +1265,14 @@ if (process.env.NODE_ENV !== 'test') {
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
console.log(`VibeTunnel New Server running on http://localhost:${PORT}`);
|
||||||
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
console.log(`Using tty-fwd: ${TTY_FWD_PATH}`);
|
||||||
if (basicAuthPassword) {
|
if (basicAuthUsername && basicAuthPassword) {
|
||||||
console.log(`${GREEN}Basic authentication: ENABLED${RESET}`);
|
console.log(`${GREEN}Basic authentication: ENABLED${RESET}`);
|
||||||
console.log('Username: admin');
|
console.log(`Username: ${basicAuthUsername}`);
|
||||||
console.log(`Password: ${basicAuthPassword}`);
|
console.log(`Password: ${basicAuthPassword}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${RED}⚠️ WARNING: Server running without authentication!${RESET}`);
|
console.log(`${RED}⚠️ WARNING: Server running without authentication!${RESET}`);
|
||||||
console.log(
|
console.log(
|
||||||
`${YELLOW}Anyone can access this server. Use --password or set VIBETUNNEL_PASSWORD.${RESET}`
|
`${YELLOW}Anyone can access this server. Use --username and --password or set VIBETUNNEL_USERNAME and VIBETUNNEL_PASSWORD.${RESET}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,8 @@ export class StreamWatcher {
|
||||||
let exitEventFound = false;
|
let exitEventFound = false;
|
||||||
let lineBuffer = '';
|
let lineBuffer = '';
|
||||||
|
|
||||||
stream.on('data', (chunk: string) => {
|
stream.on('data', (chunk: string | Buffer) => {
|
||||||
lineBuffer += chunk;
|
lineBuffer += chunk.toString();
|
||||||
const lines = lineBuffer.split('\n');
|
const lines = lineBuffer.split('\n');
|
||||||
lineBuffer = lines.pop() || ''; // Keep incomplete line for next chunk
|
lineBuffer = lines.pop() || ''; // Keep incomplete line for next chunk
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue