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:
@@ -61,6 +61,8 @@ The whole project was built entirely with AI coding agents under my supervision.
|
|||||||
- Self-serve web updates (no CLI required)
|
- Self-serve web updates (no CLI required)
|
||||||
- Update and restart keeps previous server settings (port/password)
|
- Update and restart keeps previous server settings (port/password)
|
||||||
- Cloudflare Quick Tunnel support for easy remote access (`--try-cf-tunnel`)
|
- 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)
|
### Desktop (macOS)
|
||||||
|
|
||||||
@@ -94,6 +96,8 @@ openchamber --port 8080 # Custom port
|
|||||||
openchamber --daemon # Background mode
|
openchamber --daemon # Background mode
|
||||||
openchamber --ui-password secret # Password-protect UI
|
openchamber --ui-password secret # Password-protect UI
|
||||||
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
|
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 stop # Stop server
|
||||||
openchamber update # Update to latest version
|
openchamber update # Update to latest version
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -2122,6 +2123,8 @@
|
|||||||
|
|
||||||
"pupa": ["pupa@3.3.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA=="],
|
"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=="],
|
"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=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"dev:web": "bun run --cwd packages/web build:watch",
|
"dev:web": "bun run --cwd packages/web build:watch",
|
||||||
"dev:web:server": "bun run --cwd packages/web dev:server: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: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",
|
"start:web": "bun run --cwd packages/web start",
|
||||||
"pack:web": "bun pm pack --cwd packages/web",
|
"pack:web": "bun pm pack --cwd packages/web",
|
||||||
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
"desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start",
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ interface SessionAuthGateProps {
|
|||||||
|
|
||||||
type GateState = 'pending' | 'authenticated' | 'locked' | 'error';
|
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 }) => {
|
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
|
||||||
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
|
||||||
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
@@ -94,6 +113,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const hasResyncedRef = React.useRef(skipAuth);
|
const hasResyncedRef = React.useRef(skipAuth);
|
||||||
|
const hasTriedUrlTokenRef = React.useRef(false);
|
||||||
|
|
||||||
const checkStatus = React.useCallback(async () => {
|
const checkStatus = React.useCallback(async () => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
@@ -140,6 +160,49 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
}, [state]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (skipAuth) {
|
if (skipAuth) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+44
-9
@@ -50,10 +50,30 @@ function generateRandomPassword(length = 16) {
|
|||||||
return password;
|
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() {
|
function parseArgs() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined;
|
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';
|
let command = 'serve';
|
||||||
|
|
||||||
const consumeValue = (currentIndex, inlineValue) => {
|
const consumeValue = (currentIndex, inlineValue) => {
|
||||||
@@ -99,6 +119,12 @@ function parseArgs() {
|
|||||||
case 'try-cf-tunnel':
|
case 'try-cf-tunnel':
|
||||||
options.tryCfTunnel = true;
|
options.tryCfTunnel = true;
|
||||||
break;
|
break;
|
||||||
|
case 'tunnel-qr':
|
||||||
|
options.tunnelQr = true;
|
||||||
|
break;
|
||||||
|
case 'tunnel-password-url':
|
||||||
|
options.tunnelPasswordUrl = true;
|
||||||
|
break;
|
||||||
case 'ui-password': {
|
case 'ui-password': {
|
||||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||||
i = nextIndex;
|
i = nextIndex;
|
||||||
@@ -139,12 +165,14 @@ COMMANDS:
|
|||||||
update Check for and install updates
|
update Check for and install updates
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-p, --port Web server port (default: ${DEFAULT_PORT})
|
-p, --port Web server port (default: ${DEFAULT_PORT})
|
||||||
--ui-password Protect browser UI with single password
|
--ui-password Protect browser UI with single password
|
||||||
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
|
||||||
-d, --daemon Run in background (serve command)
|
--tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel)
|
||||||
-h, --help Show help
|
--tunnel-password-url Include password in tunnel URL for auto-login
|
||||||
-v, --version Show version
|
-d, --daemon Run in background (serve command)
|
||||||
|
-h, --help Show help
|
||||||
|
-v, --version Show version
|
||||||
|
|
||||||
ENVIRONMENT:
|
ENVIRONMENT:
|
||||||
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
|
||||||
@@ -490,8 +518,15 @@ const commands = {
|
|||||||
exitOnShutdown: true,
|
exitOnShutdown: true,
|
||||||
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
|
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
|
||||||
tryCfTunnel: options.tryCfTunnel,
|
tryCfTunnel: options.tryCfTunnel,
|
||||||
onTunnelReady: (url) => {
|
onTunnelReady: async (url) => {
|
||||||
console.log(`\n🌐 Tunnel URL: \x1b[36m${url}\x1b[0m\n`);
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"start": "node bin/cli.js serve"
|
"start": "node bin/cli.js serve"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user