feat: harden console sessions + complete protocol support
Server Deploy / deploy (push) Failing after 1m9s
Agent Release / build (push) Successful in 1m52s
Agent Release / msi (push) Failing after 52s

- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener)
- wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field)
- passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
This commit is contained in:
2026-07-17 12:19:07 +01:00
parent 2fe08ad7e9
commit a9d602d021
16 changed files with 179 additions and 732 deletions
+15 -1
View File
@@ -12,9 +12,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
@@ -70,6 +71,19 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase{" "}
<span className="text-text-tertiary font-normal">(optional for an encrypted private key)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
+16
View File
@@ -22,6 +22,7 @@ export default function ServerConsolePage() {
const [sshUsername, setSshUsername] = useState<string>("root");
const [rdpUsername, setRdpUsername] = useState("");
const [rdpPassword, setRdpPassword] = useState("");
const [vncPassword, setVncPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -77,6 +78,8 @@ export default function ServerConsolePage() {
} else if (protocol === "rdp") {
body.rdp_username = rdpUsername || undefined;
body.rdp_password = rdpPassword || undefined;
} else if (protocol === "vnc") {
body.rdp_password = vncPassword || undefined;
}
const { token, ws_path } = await api.connectConsole(body);
@@ -205,6 +208,19 @@ export default function ServerConsolePage() {
</>
)}
{protocol === "vnc" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={vncPassword}
onChange={(e) => setVncPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
)}
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
Connect
</Button>
+8 -2
View File
@@ -46,6 +46,7 @@ export interface Key {
source: KeySource;
generated_by_server_id?: string;
has_private_key: boolean;
has_passphrase?: boolean;
created_at: string;
assigned_count?: number;
}
@@ -278,10 +279,15 @@ export const api = {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string, private_key?: string): Promise<Key> {
uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({ label, public_key, private_key: private_key || undefined }),
body: JSON.stringify({
label,
public_key,
private_key: private_key || undefined,
passphrase: passphrase || undefined,
}),
});
},
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+21 -6
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -18,9 +22,20 @@
}
],
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}