feat: add QR code and password URL for Cloudflare tunnel (#112)

* feat: add QR code and password URL parameter for Cloudflare tunnel

Add --tunnel-qr flag to generate scannable QR code for tunnel URL in terminal.
Add --tunnel-password-url flag to include password as URL parameter for auto-login.
Frontend now automatically detects and submits password token from URL.

* docs: update README with tunnel QR and password URL features

Add dev script for concurrent server, web, and UI watch mode.
This commit is contained in:
Martin DONADIEU
2026-01-07 21:51:45 +02:00
committed by GitHub
parent 6a06d57a95
commit 11fb61a883
6 changed files with 116 additions and 9 deletions
+4
View File
@@ -61,6 +61,8 @@ The whole project was built entirely with AI coding agents under my supervision.
- Self-serve web updates (no CLI required)
- Update and restart keeps previous server settings (port/password)
- Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`)
- QR code generation for quick mobile access (`--tunnel-qr`)
- Auto-login URL with embedded password (`--tunnel-password-url`)
### Desktop (macOS)
@@ -94,6 +96,8 @@ openchamber --port 8080 # Custom port
openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access
openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login
openchamber stop # Stop server
openchamber update # Update to latest version
```
+3
View File
@@ -218,6 +218,7 @@
"jsonc-parser": "^3.3.1",
"next-themes": "^0.4.6",
"node-pty": "^1.1.0",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
@@ -2122,6 +2123,8 @@
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
"qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+1
View File
@@ -38,6 +38,7 @@
"dev:web": "bun run --cwd packages/web build:watch",
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
"dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"",
"dev": "concurrently -n \"server,web,ui\" -c \"cyan,magenta,yellow\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\" \"bun run --cwd packages/ui dev\"",
"start:web": "bun run --cwd packages/web start",
"pack:web": "bun pm pack --cwd packages/web",
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
@@ -84,6 +84,25 @@ interface SessionAuthGateProps {
type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
const getTokenFromUrl = (): string | null => {
try {
const params = new URLSearchParams(window.location.search);
return params.get('token');
} catch {
return null;
}
};
const clearTokenFromUrl = () => {
try {
const url = new URL(window.location.href);
url.searchParams.delete('token');
window.history.replaceState({}, '', url.toString());
} catch {
// Ignore errors
}
};
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
@@ -94,6 +113,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
const [errorMessage, setErrorMessage] = React.useState('');
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const hasResyncedRef = React.useRef(skipAuth);
const hasTriedUrlTokenRef = React.useRef(false);
const checkStatus = React.useCallback(async () => {
if (skipAuth) {
@@ -140,6 +160,49 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
}
}, [state]);
// Auto-login with URL token parameter
React.useEffect(() => {
if (skipAuth || state !== 'locked' || hasTriedUrlTokenRef.current || isSubmitting) {
return;
}
const urlToken = getTokenFromUrl();
if (!urlToken) {
return;
}
hasTriedUrlTokenRef.current = true;
clearTokenFromUrl();
// Auto-submit the password from URL
setIsSubmitting(true);
setErrorMessage('');
submitPassword(urlToken)
.then((response) => {
if (response.ok) {
setPassword('');
setState('authenticated');
return;
}
if (response.status === 401) {
setErrorMessage('URL token invalid. Please enter password manually.');
setState('locked');
return;
}
setErrorMessage('Unexpected response from server.');
setState('error');
})
.catch((error) => {
console.warn('Failed to submit URL token:', error);
setErrorMessage('Network error. Check connection and retry.');
setState('error');
})
.finally(() => {
setIsSubmitting(false);
});
}, [skipAuth, state, isSubmitting]);
React.useEffect(() => {
if (skipAuth) {
return;
+44 -9
View File
@@ -50,10 +50,30 @@ function generateRandomPassword(length = 16) {
return password;
}
async function displayTunnelQrCode(url) {
try {
const qrcode = await import('qrcode-terminal');
console.log('\n📱 Scan this QR code to access the tunnel:\n');
qrcode.default.generate(url, { small: true });
console.log('');
} catch (error) {
console.warn('⚠️ Could not generate QR code:', error.message);
}
}
function buildTunnelUrl(baseUrl, password, includePassword) {
if (!includePassword || !password) {
return baseUrl;
}
const url = new URL(baseUrl);
url.searchParams.set('token', password);
return url.toString();
}
function parseArgs() {
const args = process.argv.slice(2);
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false };
const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false };
let command = 'serve';
const consumeValue = (currentIndex, inlineValue) => {
@@ -99,6 +119,12 @@ function parseArgs() {
case 'try-cf-tunnel':
options.tryCfTunnel = true;
break;
case 'tunnel-qr':
options.tunnelQr = true;
break;
case 'tunnel-password-url':
options.tunnelPasswordUrl = true;
break;
case 'ui-password': {
const { value, nextIndex } = consumeValue(i, inlineValue);
i = nextIndex;
@@ -139,12 +165,14 @@ COMMANDS:
update Check for and install updates
OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT})
--ui-password Protect browser UI with single password
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
-d, --daemon Run in background (serve command)
-h, --help Show help
-v, --version Show version
-p, --port Web server port (default: ${DEFAULT_PORT})
--ui-password Protect browser UI with single password
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
--tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel)
--tunnel-password-url Include password in tunnel URL for auto-login
-d, --daemon Run in background (serve command)
-h, --help Show help
-v, --version Show version
ENVIRONMENT:
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
@@ -490,8 +518,15 @@ const commands = {
exitOnShutdown: true,
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
tryCfTunnel: options.tryCfTunnel,
onTunnelReady: (url) => {
console.log(`\n🌐 Tunnel URL: \x1b[36m${url}\x1b[0m\n`);
onTunnelReady: async (url) => {
const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl);
console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`);
if (options.tunnelPasswordUrl && effectiveUiPassword) {
console.log('🔑 Password is embedded in URL for auto-login\n');
}
if (options.tunnelQr) {
await displayTunnelQrCode(displayUrl);
}
},
});
}
+1
View File
@@ -22,6 +22,7 @@
"start": "node bin/cli.js serve"
},
"dependencies": {
"qrcode-terminal": "^0.12.0",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",