Skip to content
Closed
14 changes: 14 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,8 @@ export interface GlobalSettingsResponse {
api_bind: string;
worker_log_mode: string;
opencode: OpenCodeSettings;
ssh_enabled: boolean;
ssh_port: number;
}

export interface GlobalSettingsUpdate {
Expand All @@ -1239,6 +1241,8 @@ export interface GlobalSettingsUpdate {
api_bind?: string;
worker_log_mode?: string;
opencode?: OpenCodeSettingsUpdate;
ssh_enabled?: boolean;
ssh_port?: number;
}

export interface GlobalSettingsUpdateResponse {
Expand All @@ -1247,6 +1251,13 @@ export interface GlobalSettingsUpdateResponse {
requires_restart: boolean;
}

export interface SshStatusResponse {
enabled: boolean;
running: boolean;
port: number;
has_authorized_key: boolean;
}

export interface RawConfigResponse {
content: string;
}
Expand Down Expand Up @@ -1955,6 +1966,9 @@ export const api = {
return response.json() as Promise<GlobalSettingsUpdateResponse>;
},

// SSH API
sshStatus: () => fetchJson<SshStatusResponse>("/ssh/status"),

// Raw config API
rawConfig: () => fetchJson<RawConfigResponse>("/config/raw"),
updateRawConfig: async (content: string) => {
Expand Down
144 changes: 142 additions & 2 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { parse as parseToml } from "smol-toml";
import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme";
import { Markdown } from "@/components/Markdown";

type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog";
type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "ssh" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog";

const SECTIONS = [
{
Expand Down Expand Up @@ -46,6 +46,12 @@ const SECTIONS = [
group: "system" as const,
description: "API server configuration",
},
{
id: "ssh" as const,
label: "SSH",
group: "system" as const,
description: "SSH server access",
},
{
id: "opencode" as const,
label: "OpenCode",
Expand Down Expand Up @@ -309,7 +315,7 @@ export function Settings() {
queryKey: ["global-settings"],
queryFn: api.globalSettings,
staleTime: 5_000,
enabled: activeSection === "api-keys" || activeSection === "server" || activeSection === "opencode" || activeSection === "worker-logs",
enabled: activeSection === "api-keys" || activeSection === "server" || activeSection === "ssh" || activeSection === "opencode" || activeSection === "worker-logs",
});

const updateMutation = useMutation({
Expand Down Expand Up @@ -700,6 +706,8 @@ export function Settings() {
<SecretsSection />
) : activeSection === "server" ? (
<ServerSection settings={globalSettings} isLoading={globalSettingsLoading} />
) : activeSection === "ssh" ? (
<SshSection settings={globalSettings} isLoading={globalSettingsLoading} />
) : activeSection === "opencode" ? (
<OpenCodeSection settings={globalSettings} isLoading={globalSettingsLoading} />
) : activeSection === "worker-logs" ? (
Expand Down Expand Up @@ -1885,6 +1893,138 @@ function ServerSection({ settings, isLoading }: GlobalSettingsSectionProps) {
);
}

function SshSection({ settings, isLoading }: GlobalSettingsSectionProps) {
const queryClient = useQueryClient();
const [sshEnabled, setSshEnabled] = useState(settings?.ssh_enabled ?? false);
const [sshPort, setSshPort] = useState(settings?.ssh_port.toString() ?? "22");
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);

const { data: sshStatus } = useQuery({
queryKey: ["ssh-status"],
queryFn: api.sshStatus,
refetchInterval: 5_000,
});
Comment on lines +1902 to +1906
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Surface ssh-status fetch failures explicitly.

If this query fails, sshStatus stays undefined and the entire status card disappears. That makes “status request failed” look the same as “no status available,” which is confusing when someone is debugging why SSH will not start.

💡 Suggested fix
- const { data: sshStatus } = useQuery({
+ const { data: sshStatus, isError: sshStatusError, error } = useQuery({
		queryKey: ["ssh-status"],
		queryFn: api.sshStatus,
		refetchInterval: 5_000,
	});
-					{sshStatus && (
+					{sshStatusError ? (
+						<div className="rounded-lg border border-red-800 bg-red-950/50 p-4 text-sm text-red-400">
+							Failed to load SSH status: {error instanceof Error ? error.message : "Unknown error"}
+						</div>
+					) : sshStatus ? (
 						<div className="rounded-lg border border-app-line bg-app-box p-4">
 							...
 						</div>
-					)}
+					) : null}

Also applies to: 1960-1976

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/routes/Settings.tsx` around lines 1902 - 1906, The ssh-status
query currently only reads { data: sshStatus } from useQuery (queryKey:
["ssh-status"], queryFn: api.sshStatus) so when it fails the card disappears;
update the hook usage to also destructure error and isLoading (e.g., { data:
sshStatus, error: sshError, isLoading }) and update the Settings status card
render to explicitly show an error state/message when sshError is present (and a
loading state when isLoading) instead of treating undefined data as “no status”;
apply the same change to the other useQuery instance that uses queryKey
["ssh-status"] at the second location.


useEffect(() => {
if (settings) {
setSshEnabled(settings.ssh_enabled);
setSshPort(settings.ssh_port.toString());
}
}, [settings]);

const updateMutation = useMutation({
mutationFn: api.updateGlobalSettings,
onSuccess: (result) => {
if (result.success) {
setMessage({ text: result.message, type: "success" });
queryClient.invalidateQueries({ queryKey: ["global-settings"] });
queryClient.invalidateQueries({ queryKey: ["ssh-status"] });
} else {
setMessage({ text: result.message, type: "error" });
}
},
onError: (error) => {
setMessage({ text: `Failed: ${error.message}`, type: "error" });
},
});

const handleSave = () => {
const port = parseInt(sshPort, 10);
if (isNaN(port) || port < 1 || port > 65535) {
setMessage({ text: "Port must be between 1 and 65535", type: "error" });
return;
}
updateMutation.mutate({
ssh_enabled: sshEnabled,
ssh_port: port,
});
};

return (
<div className="mx-auto max-w-2xl px-6 py-6">
<div className="mb-6">
<h2 className="font-plex text-sm font-semibold text-ink">SSH Server</h2>
<p className="mt-1 text-sm text-ink-dull">
Enable SSH access to this instance. Requires an authorized public key to be set by the hosting platform.
</p>
</div>

{isLoading ? (
<div className="flex items-center gap-2 text-ink-dull">
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
Loading settings...
</div>
) : (
<div className="flex flex-col gap-4">
{/* Status indicator */}
{sshStatus && (
<div className="rounded-lg border border-app-line bg-app-box p-4">
<div className="flex items-center gap-3">
<div className={`h-2 w-2 rounded-full ${sshStatus.running ? "bg-green-500" : "bg-zinc-500"}`} />
<div>
<span className="text-sm font-medium text-ink">
{sshStatus.running ? "Running" : "Stopped"}
</span>
{sshStatus.running && !sshStatus.has_authorized_key && (
<p className="text-sm text-yellow-400">
No authorized key configured. SSH connections will be rejected.
</p>
)}
</div>
</div>
</div>
)}

{/* Enable SSH toggle */}
<div className="rounded-lg border border-app-line bg-app-box p-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-ink">Enable SSH Server</span>
<p className="mt-0.5 text-sm text-ink-dull">
Start an sshd process on this instance
</p>
</div>
<Toggle size="sm" checked={sshEnabled} onCheckedChange={setSshEnabled} />
</div>
</div>

{/* Port input */}
<div className="rounded-lg border border-app-line bg-app-box p-4">
<label className="block">
<span className="text-sm font-medium text-ink">Port</span>
<p className="mt-0.5 text-sm text-ink-dull">Port number for the SSH server</p>
<Input
type="number"
value={sshPort}
onChange={(e) => setSshPort(e.target.value)}
min="1"
max="65535"
className="mt-2"
/>
</label>
</div>

<Button onClick={handleSave} loading={updateMutation.isPending}>
Save Changes
</Button>
</div>
)}

{message && (
<div
className={`mt-4 rounded-md border px-3 py-2 text-sm ${
message.type === "success"
? "border-green-500/20 bg-green-500/10 text-green-400"
: "border-red-500/20 bg-red-500/10 text-red-400"
}`}
>
{message.text}
</div>
)}
</div>
);
}

function WorkerLogsSection({ settings, isLoading }: GlobalSettingsSectionProps) {
const queryClient = useQueryClient();
const [logMode, setLogMode] = useState(settings?.worker_log_mode ?? "errors_only");
Expand Down
1 change: 1 addition & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod secrets;
mod server;
mod settings;
mod skills;
mod ssh;
mod state;
mod system;
mod tasks;
Expand Down
9 changes: 7 additions & 2 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
use super::state::ApiState;
use super::{
agents, bindings, channels, config, cortex, cron, ingest, links, mcp, memories, messaging,
models, opencode_proxy, projects, providers, secrets, settings, skills, system, tasks, tools,
webchat, workers,
models, opencode_proxy, projects, providers, secrets, settings, skills, ssh, system, tasks,
tools, webchat, workers,
};

use axum::Json;
Expand Down Expand Up @@ -242,6 +242,11 @@ pub async fn start_http_server(
.route("/changelog", get(settings::changelog))
.route("/webchat/send", post(webchat::webchat_send))
.route("/webchat/history", get(webchat::webchat_history))
.route("/ssh/status", get(ssh::ssh_status))
.route(
"/ssh/authorized-key",
put(ssh::set_authorized_key).delete(ssh::clear_authorized_keys),
)
Comment on lines +245 to +249
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject malformed public keys before exposing this endpoint.

The handler behind this route (src/api/ssh.rs:71-110) only prefix-matches the submitted line before writing it. Inputs like ssh-ed25519 not-base64 will be accepted and reported as success, but sshd will reject every login while status still claims an authorized key is configured.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/server.rs` around lines 245 - 249, The set_authorized_key handler
currently accepts prefix-matching input and must instead fully validate the
submitted public key line before writing; update the set_authorized_key function
in src/api/ssh.rs to parse the line into algorithm, base64 blob, and optional
comment, ensure the algorithm is one of the allowed types (e.g., ssh-ed25519,
ssh-rsa, ecdsa-sha2-...), verify the base64 blob decodes successfully and
matches expected length/format for the algorithm, and return a 400 error
(without writing to disk) for any malformed key; only write the key and report
success if validation passes.

.route("/links", get(links::list_links).post(links::create_link))
.route(
"/links/{from}/{to}",
Expand Down
Loading
Loading