From dc570cc6e93d5e42f679f3dcf8ef43d912748105 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 27 Feb 2026 07:25:46 -0800 Subject: [PATCH 1/5] Add SSH shell and NSS guest user support --- scripts/nss/libnss_toes.c | 87 ++++++++++++++ scripts/setup-ssh.sh | 90 +++++++++++++++ src/cli/index.ts | 11 +- src/cli/setup.ts | 10 ++ src/cli/shell.ts | 231 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 scripts/nss/libnss_toes.c create mode 100755 scripts/setup-ssh.sh create mode 100644 src/cli/shell.ts diff --git a/scripts/nss/libnss_toes.c b/scripts/nss/libnss_toes.c new file mode 100644 index 0000000..25feadf --- /dev/null +++ b/scripts/nss/libnss_toes.c @@ -0,0 +1,87 @@ +/* + * libnss_toes - NSS module that resolves unknown usernames to a shared account. + * + * Any username not found in /etc/passwd gets mapped to a toes-guest entry + * with home=/home/toes and shell=/usr/local/bin/toes. + * + * Build: + * gcc -shared -o libnss_toes.so.2 libnss_toes.c -fPIC + * + * Install: + * cp libnss_toes.so.2 /lib/ + * # Add "toes" to passwd line in /etc/nsswitch.conf + */ + +#include +#include +#include +#include + +#define GUEST_UID 65534 +#define GUEST_GID 65534 +#define GUEST_HOME "/home/toes" +#define GUEST_SHELL "/usr/local/bin/toes" +#define GUEST_GECOS "Toes Guest" + +static const char *skip_users[] = { + "root", "toes", "nobody", "daemon", "bin", "sys", "sync", + "games", "man", "lp", "mail", "news", "uucp", "proxy", + "www-data", "backup", "list", "irc", "gnats", "systemd-network", + "systemd-resolve", "messagebus", "sshd", "_apt", + NULL +}; + +static int should_skip(const char *name) { + for (const char **p = skip_users; *p; p++) { + if (strcmp(name, *p) == 0) return 1; + } + return 0; +} + +static enum nss_status fill_passwd(const char *name, struct passwd *pw, + char *buf, size_t buflen, int *errnop) { + size_t namelen = strlen(name) + 1; + size_t gecoslen = strlen(GUEST_GECOS) + 1; + size_t homelen = strlen(GUEST_HOME) + 1; + size_t shelllen = strlen(GUEST_SHELL) + 1; + size_t needed = namelen + gecoslen + homelen + shelllen + 1; /* +1 for pw_passwd "" */ + + if (buflen < needed) { + *errnop = ERANGE; + return NSS_STATUS_TRYAGAIN; + } + + char *ptr = buf; + + pw->pw_name = ptr; + memcpy(ptr, name, namelen); + ptr += namelen; + + pw->pw_passwd = ptr; + *ptr++ = '\0'; + + pw->pw_uid = GUEST_UID; + pw->pw_gid = GUEST_GID; + + pw->pw_gecos = ptr; + memcpy(ptr, GUEST_GECOS, gecoslen); + ptr += gecoslen; + + pw->pw_dir = ptr; + memcpy(ptr, GUEST_HOME, homelen); + ptr += homelen; + + pw->pw_shell = ptr; + memcpy(ptr, GUEST_SHELL, shelllen); + + return NSS_STATUS_SUCCESS; +} + +enum nss_status _nss_toes_getpwnam_r(const char *name, struct passwd *pw, + char *buf, size_t buflen, int *errnop) { + if (!name || !*name || should_skip(name)) { + return NSS_STATUS_NOTFOUND; + } + + return fill_passwd(name, pw, buf, buflen, errnop); +} diff --git a/scripts/setup-ssh.sh b/scripts/setup-ssh.sh new file mode 100755 index 0000000..723ced2 --- /dev/null +++ b/scripts/setup-ssh.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# +# setup-ssh.sh - Configure SSH so any user gets the toes CLI +# +# This script: +# 1. Compiles and installs the NSS module +# 2. Adds "toes" to nsswitch.conf passwd line +# 3. Configures PAM to accept any password (home network appliance) +# 4. Ensures PasswordAuthentication is enabled in sshd +# 5. Adds /usr/local/bin/toes to /etc/shells +# 6. Restarts sshd +# +# Run as root on the toes machine. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==> Setting up SSH auto-CLI for toes" + +# 1. Compile and install NSS module +echo " Compiling NSS module..." +gcc -shared -o /tmp/libnss_toes.so.2 "$SCRIPT_DIR/nss/libnss_toes.c" -fPIC +cp /tmp/libnss_toes.so.2 /lib/ +ldconfig +echo " Installed libnss_toes.so.2" + +# 2. Add toes to nsswitch.conf +if ! grep -q 'passwd:.*toes' /etc/nsswitch.conf; then + sed -i 's/^passwd:.*/& toes/' /etc/nsswitch.conf + echo " Added toes to nsswitch.conf" +else + echo " nsswitch.conf already configured" +fi + +# 3. Configure PAM - accept any password for SSH +if ! grep -q 'pam_permit.so.*# toes' /etc/pam.d/sshd; then + # Comment out existing auth and replace with pam_permit + sed -i '/^@include common-auth/s/^/# /' /etc/pam.d/sshd + sed -i '/^auth/s/^/# /' /etc/pam.d/sshd + # Add pam_permit after the commented lines + echo 'auth sufficient pam_permit.so # toes' >> /etc/pam.d/sshd + echo " Configured PAM for passwordless SSH" +else + echo " PAM already configured" +fi + +# 4. Ensure PasswordAuthentication yes in sshd_config +SSHD_CONFIG="/etc/ssh/sshd_config" +if grep -q '^PasswordAuthentication no' "$SSHD_CONFIG"; then + sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' "$SSHD_CONFIG" + echo " Enabled PasswordAuthentication" +elif grep -q '^#PasswordAuthentication' "$SSHD_CONFIG"; then + sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' "$SSHD_CONFIG" + echo " Enabled PasswordAuthentication" +elif ! grep -q '^PasswordAuthentication yes' "$SSHD_CONFIG"; then + echo 'PasswordAuthentication yes' >> "$SSHD_CONFIG" + echo " Added PasswordAuthentication yes" +else + echo " PasswordAuthentication already enabled" +fi + +# 5. Ensure /usr/local/bin/toes is in /etc/shells +TOES_SHELL="/usr/local/bin/toes" +if ! grep -q "^${TOES_SHELL}$" /etc/shells; then + echo "$TOES_SHELL" >> /etc/shells + echo " Added $TOES_SHELL to /etc/shells" +else + echo " $TOES_SHELL already in /etc/shells" +fi + +# Ensure the toes binary exists (symlink to bun entry point) +if [ ! -f "$TOES_SHELL" ]; then + echo " WARNING: $TOES_SHELL does not exist yet" + echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL" +fi + +# Ensure /home/toes exists for guest sessions +if [ ! -d /home/toes ]; then + mkdir -p /home/toes + chmod 755 /home/toes + echo " Created /home/toes" +fi + +# 6. Restart sshd +echo " Restarting sshd..." +systemctl restart sshd || service ssh restart || true + +echo "==> Done. Any SSH user will now get the toes CLI." +echo " toes@toes.local still gets a regular shell." diff --git a/src/cli/index.ts b/src/cli/index.ts index b60b2f7..c871de3 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,13 @@ #!/usr/bin/env bun import { program } from './setup' -program.parse() +const isSSH = !!process.env.SSH_CONNECTION +const noArgs = process.argv.length <= 2 +const isTTY = !!process.stdin.isTTY + +if (isSSH && noArgs && isTTY) { + const { shell } = await import('./shell') + await shell() +} else { + program.parse() +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 643cc71..46279e4 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -322,4 +322,14 @@ program .option('-v, --version ', 'version to rollback to (prompts if omitted)') .action((name, options) => rollbackApp(name, options.version)) +// Shell + +program + .command('shell') + .description('Interactive shell') + .action(async () => { + const { shell } = await import('./shell') + await shell() + }) + export { program } diff --git a/src/cli/shell.ts b/src/cli/shell.ts new file mode 100644 index 0000000..995a9cd --- /dev/null +++ b/src/cli/shell.ts @@ -0,0 +1,231 @@ +import type { App } from '@types' + +import * as readline from 'readline' + +import color from 'kleur' + +import { get, handleError, HOST } from './http' +import { program } from './setup' +import { STATE_ICONS } from './commands/manage' + +let appNamesCache: string[] = [] +let appNamesCacheTime = 0 + +const APP_CACHE_TTL = 5000 + +function tokenize(input: string): string[] { + const tokens: string[] = [] + let current = '' + let quote: string | null = null + + for (const ch of input) { + if (quote) { + if (ch === quote) { + quote = null + } else { + current += ch + } + } else if (ch === '"' || ch === "'") { + quote = ch + } else if (ch === ' ' || ch === '\t') { + if (current) { + tokens.push(current) + current = '' + } + } else { + current += ch + } + } + if (current) tokens.push(current) + return tokens +} + +async function fetchAppNames(): Promise { + const now = Date.now() + if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) { + return appNamesCache + } + try { + const apps = await get('/api/apps') + if (apps) { + appNamesCache = apps.map(a => a.name) + appNamesCacheTime = now + } + } catch { + // use stale cache + } + return appNamesCache +} + +function getCommandNames(): string[] { + return program.commands + .filter((cmd: { _hidden?: boolean }) => !cmd._hidden) + .map((cmd: { name: () => string }) => cmd.name()) +} + +async function printBanner(): Promise { + const apps = await get('/api/apps') + if (!apps) { + console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`) + console.log() + return + } + + // Cache app names from banner fetch + appNamesCache = apps.map(a => a.name) + appNamesCacheTime = Date.now() + + const visibleApps = apps.filter(a => !a.tool) + + console.log() + console.log(color.bold().cyan(' \u{1F43E} Toes') + ` ${HOST}`) + console.log() + + // App status line + const parts = visibleApps.map(a => { + const icon = STATE_ICONS[a.state] ?? '\u25CB' + return `${icon} ${a.name}` + }) + if (parts.length > 0) { + console.log(' ' + parts.join(' ')) + console.log() + } + + const running = visibleApps.filter(a => a.state === 'running').length + const stopped = visibleApps.filter(a => a.state !== 'running').length + const summary = [] + if (running) summary.push(`${running} running`) + if (stopped) summary.push(`${stopped} stopped`) + if (summary.length > 0) { + console.log(color.gray(` ${summary.join(', ')} \u2014 type "help" for commands`)) + } else { + console.log(color.gray(' no apps \u2014 type "help" for commands')) + } + console.log() +} + +export async function shell(): Promise { + await printBanner() + + // Configure Commander to throw instead of exiting + program.exitOverride() + program.configureOutput({ + writeOut: (str: string) => process.stdout.write(str), + writeErr: (str: string) => process.stderr.write(str), + }) + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: color.cyan('toes> '), + completer: (line: string, callback: (err: null, result: [string[], string]) => void) => { + const tokens = tokenize(line) + const trailing = line.endsWith(' ') + + if (tokens.length === 0 || (tokens.length === 1 && !trailing)) { + // Complete command names + const partial = tokens[0] ?? '' + const commands = getCommandNames() + const hits = commands.filter(c => c.startsWith(partial)) + callback(null, [hits, partial]) + } else { + // Complete app names + const partial = trailing ? '' : (tokens[tokens.length - 1] ?? '') + const names = appNamesCache + const hits = names.filter(n => n.startsWith(partial)) + callback(null, [hits, partial]) + } + }, + }) + + // Refresh app names cache in background for tab completion + fetchAppNames() + + let activeAbort: AbortController | null = null + + rl.on('SIGINT', () => { + if (activeAbort) { + activeAbort.abort() + activeAbort = null + console.log() + rl.prompt() + } else { + // Clear current line + rl.write(null, { ctrl: true, name: 'u' }) + console.log() + rl.prompt() + } + }) + + rl.prompt() + + for await (const line of rl) { + const input = line.trim() + + if (!input) { + rl.prompt() + continue + } + + if (input === 'exit' || input === 'quit') { + break + } + + if (input === 'clear') { + console.clear() + rl.prompt() + continue + } + + if (input === 'help') { + program.outputHelp() + rl.prompt() + continue + } + + const tokens = tokenize(input) + + // Wrap fetch with AbortController for this command + activeAbort = new AbortController() + const originalFetch = globalThis.fetch + const signal = activeAbort.signal + globalThis.fetch = (url, init) => + originalFetch(url, { ...init, signal }) + + // Pause readline so commands can use their own prompts + rl.pause() + + try { + await program.parseAsync(['node', 'toes', ...tokens]) + } catch (err: unknown) { + // Commander throws on exitOverride — suppress help/version exits + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code: string }).code + if (code === 'commander.helpDisplayed' || code === 'commander.version') { + // Already printed, just continue + } else if (code === 'commander.unknownCommand') { + console.error(`Unknown command: ${tokens[0]}`) + } else { + // Other Commander errors (missing arg, etc.) + // Commander already printed the error message + } + } else if (signal.aborted) { + // Command was cancelled by Ctrl+C + } else { + handleError(err) + } + } finally { + globalThis.fetch = originalFetch + activeAbort = null + } + + // Refresh app names cache after commands that might change state + fetchAppNames() + + rl.resume() + rl.prompt() + } + + rl.close() + console.log() +} From a87f0a9651258c68f941dd34f453e0c5ffa5663e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 28 Feb 2026 13:34:14 -0800 Subject: [PATCH 2/5] Add abort signals; rename guest to toes-cli --- scripts/nss/libnss_toes.c | 14 +++++++------- scripts/setup-ssh.sh | 19 ++++++++++++++----- src/cli/commands/cron.ts | 4 ++-- src/cli/commands/logs.ts | 4 ++-- src/cli/commands/sync.ts | 6 +++--- src/cli/http.ts | 15 ++++++++++++--- src/cli/shell.ts | 10 ++++------ 7 files changed, 44 insertions(+), 28 deletions(-) diff --git a/scripts/nss/libnss_toes.c b/scripts/nss/libnss_toes.c index 25feadf..1370214 100644 --- a/scripts/nss/libnss_toes.c +++ b/scripts/nss/libnss_toes.c @@ -1,8 +1,8 @@ /* * libnss_toes - NSS module that resolves unknown usernames to a shared account. * - * Any username not found in /etc/passwd gets mapped to a toes-guest entry - * with home=/home/toes and shell=/usr/local/bin/toes. + * Any username not found in /etc/passwd gets mapped to a toes-cli entry + * with home=/home/toes-cli and shell=/usr/local/bin/toes. * * Build: * gcc -shared -o libnss_toes.so.2 libnss_toes.c -fPIC @@ -17,14 +17,14 @@ #include #include -#define GUEST_UID 65534 -#define GUEST_GID 65534 -#define GUEST_HOME "/home/toes" +#define GUEST_UID 3001 +#define GUEST_GID 3001 +#define GUEST_HOME "/home/toes-cli" #define GUEST_SHELL "/usr/local/bin/toes" -#define GUEST_GECOS "Toes Guest" +#define GUEST_GECOS "Toes CLI" static const char *skip_users[] = { - "root", "toes", "nobody", "daemon", "bin", "sys", "sync", + "root", "toes", "toes-cli", "nobody", "daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp", "proxy", "www-data", "backup", "list", "irc", "gnats", "systemd-network", "systemd-resolve", "messagebus", "sshd", "_apt", diff --git a/scripts/setup-ssh.sh b/scripts/setup-ssh.sh index 723ced2..89c2481 100755 --- a/scripts/setup-ssh.sh +++ b/scripts/setup-ssh.sh @@ -75,11 +75,19 @@ if [ ! -f "$TOES_SHELL" ]; then echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL" fi -# Ensure /home/toes exists for guest sessions -if [ ! -d /home/toes ]; then - mkdir -p /home/toes - chmod 755 /home/toes - echo " Created /home/toes" +# Create toes-cli system user for guest SSH sessions +if ! id toes-cli &>/dev/null; then + useradd --system --uid 3001 --home-dir /home/toes-cli --shell /usr/local/bin/toes --create-home toes-cli + echo " Created toes-cli user" +else + echo " toes-cli user already exists" +fi + +# Ensure /home/toes-cli exists for guest sessions +if [ ! -d /home/toes-cli ]; then + mkdir -p /home/toes-cli + chmod 755 /home/toes-cli + echo " Created /home/toes-cli" fi # 6. Restart sshd @@ -87,4 +95,5 @@ echo " Restarting sshd..." systemctl restart sshd || service ssh restart || true echo "==> Done. Any SSH user will now get the toes CLI." +echo " SSH users are mapped to the toes-cli account (UID 3001)." echo " toes@toes.local still gets a regular shell." diff --git a/src/cli/commands/cron.ts b/src/cli/commands/cron.ts index 2f82ad8..976e810 100644 --- a/src/cli/commands/cron.ts +++ b/src/cli/commands/cron.ts @@ -1,6 +1,6 @@ import type { LogLine } from '@types' import color from 'kleur' -import { get, handleError, makeUrl, post } from '../http' +import { activeSignal, get, handleError, makeUrl, post } from '../http' import { resolveAppName } from '../name' interface CronJobSummary { @@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) => async function tailCronLogs(app: string, grep?: string) { try { const url = makeUrl(`/api/apps/${app}/logs/stream`) - const res = await fetch(url) + const res = await fetch(url, { signal: activeSignal }) if (!res.ok) { console.error(`App not found: ${app}`) return diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 23798d0..f9f09fb 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -1,5 +1,5 @@ import type { LogLine } from '@types' -import { get, handleError, makeUrl } from '../http' +import { activeSignal, get, handleError, makeUrl } from '../http' import { resolveAppName } from '../name' interface LogOptions { @@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) { export async function tailLogs(name: string, grep?: string) { try { const url = makeUrl(`/api/apps/${name}/logs/stream`) - const res = await fetch(url) + const res = await fetch(url, { signal: activeSignal }) if (!res.ok) { console.error(`App not found: ${name}`) return diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index d5b0e09..4cb7761 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -5,7 +5,7 @@ import color from 'kleur' import { diffLines } from 'diff' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' -import { del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' +import { activeSignal, del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' import { confirm, prompt } from '../prompts' import { getAppName, getAppPackage, isApp, resolveAppName } from '../name' @@ -592,7 +592,7 @@ export async function syncApp() { const url = makeUrl(`/api/sync/apps/${appName}/watch`) let res: Response try { - res = await fetch(url) + res = await fetch(url, { signal: activeSignal }) if (!res.ok) { console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) watcher.close() @@ -967,7 +967,7 @@ interface VersionsResponse { async function getVersions(appName: string): Promise { try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`)) + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: activeSignal }) if (res.status === 404) { console.error(`App not found: ${appName}`) return null diff --git a/src/cli/http.ts b/src/cli/http.ts index 85a642f..7907c27 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -1,6 +1,8 @@ import type { Manifest } from '@types' const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local' +export let activeSignal: AbortSignal | undefined + const normalizeUrl = (url: string) => url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}` @@ -17,6 +19,10 @@ export const HOST = process.env.TOES_URL ? normalizeUrl(process.env.TOES_URL) : DEFAULT_HOST +export const clearSignal = () => { activeSignal = undefined } + +export const setSignal = (signal: AbortSignal) => { activeSignal = signal } + export function makeUrl(path: string): string { return `${HOST}${path}` } @@ -36,7 +42,7 @@ export function handleError(error: unknown): void { export async function get(url: string): Promise { try { - const res = await fetch(makeUrl(url)) + const res = await fetch(makeUrl(url), { signal: activeSignal }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -50,7 +56,7 @@ export async function get(url: string): Promise { export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`)) + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: activeSignal }) if (res.status === 404) return { exists: false } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) const data = await res.json() @@ -68,6 +74,7 @@ export async function post(url: string, body?: B): Promise { try { const fullUrl = makeUrl(url) - const res = await fetch(fullUrl) + const res = await fetch(fullUrl, { signal: activeSignal }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -117,6 +125,7 @@ export async function del(url: string): Promise { try { const res = await fetch(makeUrl(url), { method: 'DELETE', + signal: activeSignal, }) if (!res.ok) { const text = await res.text() diff --git a/src/cli/shell.ts b/src/cli/shell.ts index 995a9cd..8f83619 100644 --- a/src/cli/shell.ts +++ b/src/cli/shell.ts @@ -4,7 +4,7 @@ import * as readline from 'readline' import color from 'kleur' -import { get, handleError, HOST } from './http' +import { clearSignal, get, handleError, HOST, setSignal } from './http' import { program } from './setup' import { STATE_ICONS } from './commands/manage' @@ -185,12 +185,10 @@ export async function shell(): Promise { const tokens = tokenize(input) - // Wrap fetch with AbortController for this command + // Set up AbortController for this command activeAbort = new AbortController() - const originalFetch = globalThis.fetch const signal = activeAbort.signal - globalThis.fetch = (url, init) => - originalFetch(url, { ...init, signal }) + setSignal(signal) // Pause readline so commands can use their own prompts rl.pause() @@ -215,7 +213,7 @@ export async function shell(): Promise { handleError(err) } } finally { - globalThis.fetch = originalFetch + clearSignal() activeAbort = null } From 460d625f601d34aefd85498bc75e81e6128cbf17 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 28 Feb 2026 22:38:39 -0800 Subject: [PATCH 3/5] Simplify SSH access via dedicated cli user --- scripts/nss/libnss_toes.c | 87 ----------------------------- scripts/setup-ssh.sh | 114 +++++++++++++------------------------- src/cli/index.ts | 4 +- 3 files changed, 42 insertions(+), 163 deletions(-) delete mode 100644 scripts/nss/libnss_toes.c diff --git a/scripts/nss/libnss_toes.c b/scripts/nss/libnss_toes.c deleted file mode 100644 index 1370214..0000000 --- a/scripts/nss/libnss_toes.c +++ /dev/null @@ -1,87 +0,0 @@ -/* - * libnss_toes - NSS module that resolves unknown usernames to a shared account. - * - * Any username not found in /etc/passwd gets mapped to a toes-cli entry - * with home=/home/toes-cli and shell=/usr/local/bin/toes. - * - * Build: - * gcc -shared -o libnss_toes.so.2 libnss_toes.c -fPIC - * - * Install: - * cp libnss_toes.so.2 /lib/ - * # Add "toes" to passwd line in /etc/nsswitch.conf - */ - -#include -#include -#include -#include - -#define GUEST_UID 3001 -#define GUEST_GID 3001 -#define GUEST_HOME "/home/toes-cli" -#define GUEST_SHELL "/usr/local/bin/toes" -#define GUEST_GECOS "Toes CLI" - -static const char *skip_users[] = { - "root", "toes", "toes-cli", "nobody", "daemon", "bin", "sys", "sync", - "games", "man", "lp", "mail", "news", "uucp", "proxy", - "www-data", "backup", "list", "irc", "gnats", "systemd-network", - "systemd-resolve", "messagebus", "sshd", "_apt", - NULL -}; - -static int should_skip(const char *name) { - for (const char **p = skip_users; *p; p++) { - if (strcmp(name, *p) == 0) return 1; - } - return 0; -} - -static enum nss_status fill_passwd(const char *name, struct passwd *pw, - char *buf, size_t buflen, int *errnop) { - size_t namelen = strlen(name) + 1; - size_t gecoslen = strlen(GUEST_GECOS) + 1; - size_t homelen = strlen(GUEST_HOME) + 1; - size_t shelllen = strlen(GUEST_SHELL) + 1; - size_t needed = namelen + gecoslen + homelen + shelllen + 1; /* +1 for pw_passwd "" */ - - if (buflen < needed) { - *errnop = ERANGE; - return NSS_STATUS_TRYAGAIN; - } - - char *ptr = buf; - - pw->pw_name = ptr; - memcpy(ptr, name, namelen); - ptr += namelen; - - pw->pw_passwd = ptr; - *ptr++ = '\0'; - - pw->pw_uid = GUEST_UID; - pw->pw_gid = GUEST_GID; - - pw->pw_gecos = ptr; - memcpy(ptr, GUEST_GECOS, gecoslen); - ptr += gecoslen; - - pw->pw_dir = ptr; - memcpy(ptr, GUEST_HOME, homelen); - ptr += homelen; - - pw->pw_shell = ptr; - memcpy(ptr, GUEST_SHELL, shelllen); - - return NSS_STATUS_SUCCESS; -} - -enum nss_status _nss_toes_getpwnam_r(const char *name, struct passwd *pw, - char *buf, size_t buflen, int *errnop) { - if (!name || !*name || should_skip(name)) { - return NSS_STATUS_NOTFOUND; - } - - return fill_passwd(name, pw, buf, buflen, errnop); -} diff --git a/scripts/setup-ssh.sh b/scripts/setup-ssh.sh index 89c2481..b22b85b 100755 --- a/scripts/setup-ssh.sh +++ b/scripts/setup-ssh.sh @@ -1,67 +1,50 @@ #!/bin/bash # -# setup-ssh.sh - Configure SSH so any user gets the toes CLI +# setup-ssh.sh - Configure SSH for the toes CLI user # # This script: -# 1. Compiles and installs the NSS module -# 2. Adds "toes" to nsswitch.conf passwd line -# 3. Configures PAM to accept any password (home network appliance) -# 4. Ensures PasswordAuthentication is enabled in sshd -# 5. Adds /usr/local/bin/toes to /etc/shells -# 6. Restarts sshd +# 1. Creates a `cli` system user with /usr/local/bin/toes as shell +# 2. Sets an empty password on `cli` for passwordless SSH +# 3. Adds a Match block in sshd_config to allow empty passwords for `cli` +# 4. Adds /usr/local/bin/toes to /etc/shells +# 5. Restarts sshd # # Run as root on the toes machine. +# Usage: ssh cli@toes.local set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -echo "==> Setting up SSH auto-CLI for toes" - -# 1. Compile and install NSS module -echo " Compiling NSS module..." -gcc -shared -o /tmp/libnss_toes.so.2 "$SCRIPT_DIR/nss/libnss_toes.c" -fPIC -cp /tmp/libnss_toes.so.2 /lib/ -ldconfig -echo " Installed libnss_toes.so.2" - -# 2. Add toes to nsswitch.conf -if ! grep -q 'passwd:.*toes' /etc/nsswitch.conf; then - sed -i 's/^passwd:.*/& toes/' /etc/nsswitch.conf - echo " Added toes to nsswitch.conf" -else - echo " nsswitch.conf already configured" -fi - -# 3. Configure PAM - accept any password for SSH -if ! grep -q 'pam_permit.so.*# toes' /etc/pam.d/sshd; then - # Comment out existing auth and replace with pam_permit - sed -i '/^@include common-auth/s/^/# /' /etc/pam.d/sshd - sed -i '/^auth/s/^/# /' /etc/pam.d/sshd - # Add pam_permit after the commented lines - echo 'auth sufficient pam_permit.so # toes' >> /etc/pam.d/sshd - echo " Configured PAM for passwordless SSH" -else - echo " PAM already configured" -fi - -# 4. Ensure PasswordAuthentication yes in sshd_config -SSHD_CONFIG="/etc/ssh/sshd_config" -if grep -q '^PasswordAuthentication no' "$SSHD_CONFIG"; then - sed -i 's/^PasswordAuthentication no/PasswordAuthentication yes/' "$SSHD_CONFIG" - echo " Enabled PasswordAuthentication" -elif grep -q '^#PasswordAuthentication' "$SSHD_CONFIG"; then - sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' "$SSHD_CONFIG" - echo " Enabled PasswordAuthentication" -elif ! grep -q '^PasswordAuthentication yes' "$SSHD_CONFIG"; then - echo 'PasswordAuthentication yes' >> "$SSHD_CONFIG" - echo " Added PasswordAuthentication yes" -else - echo " PasswordAuthentication already enabled" -fi - -# 5. Ensure /usr/local/bin/toes is in /etc/shells TOES_SHELL="/usr/local/bin/toes" +SSHD_CONFIG="/etc/ssh/sshd_config" + +echo "==> Setting up SSH CLI user for toes" + +# 1. Create cli system user +if ! id cli &>/dev/null; then + useradd --system --home-dir /home/cli --shell "$TOES_SHELL" --create-home cli + echo " Created cli user" +else + echo " cli user already exists" +fi + +# 2. Set empty password +passwd -d cli +echo " Set empty password on cli" + +# 3. Add Match block for cli user in sshd_config +if ! grep -q 'Match User cli' "$SSHD_CONFIG"; then + cat >> "$SSHD_CONFIG" <> /etc/shells echo " Added $TOES_SHELL to /etc/shells" @@ -69,31 +52,14 @@ else echo " $TOES_SHELL already in /etc/shells" fi -# Ensure the toes binary exists (symlink to bun entry point) +# Warn if toes binary doesn't exist yet if [ ! -f "$TOES_SHELL" ]; then echo " WARNING: $TOES_SHELL does not exist yet" echo " Create it with: ln -sf /path/to/toes/cli $TOES_SHELL" fi -# Create toes-cli system user for guest SSH sessions -if ! id toes-cli &>/dev/null; then - useradd --system --uid 3001 --home-dir /home/toes-cli --shell /usr/local/bin/toes --create-home toes-cli - echo " Created toes-cli user" -else - echo " toes-cli user already exists" -fi - -# Ensure /home/toes-cli exists for guest sessions -if [ ! -d /home/toes-cli ]; then - mkdir -p /home/toes-cli - chmod 755 /home/toes-cli - echo " Created /home/toes-cli" -fi - -# 6. Restart sshd +# 5. Restart sshd echo " Restarting sshd..." systemctl restart sshd || service ssh restart || true -echo "==> Done. Any SSH user will now get the toes CLI." -echo " SSH users are mapped to the toes-cli account (UID 3001)." -echo " toes@toes.local still gets a regular shell." +echo "==> Done. Connect with: ssh cli@toes.local" diff --git a/src/cli/index.ts b/src/cli/index.ts index c871de3..1a16599 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun import { program } from './setup' -const isSSH = !!process.env.SSH_CONNECTION +const isCliUser = process.env.USER === 'cli' const noArgs = process.argv.length <= 2 const isTTY = !!process.stdin.isTTY -if (isSSH && noArgs && isTTY) { +if (isCliUser && noArgs && isTTY) { const { shell } = await import('./shell') await shell() } else { From 5f1de651eb3b65795e338574affbbd8753c10ebf Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 28 Feb 2026 22:48:38 -0800 Subject: [PATCH 4/5] Use AsyncLocalStorage for abort signal propagation --- src/cli/commands/cron.ts | 4 ++-- src/cli/commands/logs.ts | 4 ++-- src/cli/commands/sync.ts | 6 +++--- src/cli/http.ts | 23 +++++++++++++---------- src/cli/shell.ts | 6 ++---- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/cron.ts b/src/cli/commands/cron.ts index 976e810..72912eb 100644 --- a/src/cli/commands/cron.ts +++ b/src/cli/commands/cron.ts @@ -1,6 +1,6 @@ import type { LogLine } from '@types' import color from 'kleur' -import { activeSignal, get, handleError, makeUrl, post } from '../http' +import { get, getSignal, handleError, makeUrl, post } from '../http' import { resolveAppName } from '../name' interface CronJobSummary { @@ -195,7 +195,7 @@ const printCronLog = (line: LogLine) => async function tailCronLogs(app: string, grep?: string) { try { const url = makeUrl(`/api/apps/${app}/logs/stream`) - const res = await fetch(url, { signal: activeSignal }) + const res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`App not found: ${app}`) return diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index f9f09fb..81f0655 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -1,5 +1,5 @@ import type { LogLine } from '@types' -import { activeSignal, get, handleError, makeUrl } from '../http' +import { get, getSignal, handleError, makeUrl } from '../http' import { resolveAppName } from '../name' interface LogOptions { @@ -120,7 +120,7 @@ export async function logApp(arg: string | undefined, options: LogOptions) { export async function tailLogs(name: string, grep?: string) { try { const url = makeUrl(`/api/apps/${name}/logs/stream`) - const res = await fetch(url, { signal: activeSignal }) + const res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`App not found: ${name}`) return diff --git a/src/cli/commands/sync.ts b/src/cli/commands/sync.ts index 4cb7761..178e13d 100644 --- a/src/cli/commands/sync.ts +++ b/src/cli/commands/sync.ts @@ -5,7 +5,7 @@ import color from 'kleur' import { diffLines } from 'diff' import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, watch, writeFileSync } from 'fs' import { dirname, join } from 'path' -import { activeSignal, del, download, get, getManifest, handleError, makeUrl, post, put } from '../http' +import { del, download, get, getManifest, getSignal, handleError, makeUrl, post, put } from '../http' import { confirm, prompt } from '../prompts' import { getAppName, getAppPackage, isApp, resolveAppName } from '../name' @@ -592,7 +592,7 @@ export async function syncApp() { const url = makeUrl(`/api/sync/apps/${appName}/watch`) let res: Response try { - res = await fetch(url, { signal: activeSignal }) + res = await fetch(url, { signal: getSignal() }) if (!res.ok) { console.error(`Failed to connect to server: ${res.status} ${res.statusText}`) watcher.close() @@ -967,7 +967,7 @@ interface VersionsResponse { async function getVersions(appName: string): Promise { try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: activeSignal }) + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/versions`), { signal: getSignal() }) if (res.status === 404) { console.error(`App not found: ${appName}`) return null diff --git a/src/cli/http.ts b/src/cli/http.ts index 7907c27..a955b03 100644 --- a/src/cli/http.ts +++ b/src/cli/http.ts @@ -1,7 +1,8 @@ import type { Manifest } from '@types' -const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local' +import { AsyncLocalStorage } from 'node:async_hooks' -export let activeSignal: AbortSignal | undefined +const DEFAULT_HOST = process.env.DEV ? 'http://localhost:3000' : 'http://toes.local' +const signalStore = new AsyncLocalStorage() const normalizeUrl = (url: string) => url.startsWith('http://') || url.startsWith('https://') ? url : `http://${url}` @@ -19,9 +20,11 @@ export const HOST = process.env.TOES_URL ? normalizeUrl(process.env.TOES_URL) : DEFAULT_HOST -export const clearSignal = () => { activeSignal = undefined } +export const getSignal = () => signalStore.getStore() -export const setSignal = (signal: AbortSignal) => { activeSignal = signal } +export function withSignal(signal: AbortSignal, fn: () => T): T { + return signalStore.run(signal, fn) +} export function makeUrl(path: string): string { return `${HOST}${path}` @@ -42,7 +45,7 @@ export function handleError(error: unknown): void { export async function get(url: string): Promise { try { - const res = await fetch(makeUrl(url), { signal: activeSignal }) + const res = await fetch(makeUrl(url), { signal: getSignal() }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -56,7 +59,7 @@ export async function get(url: string): Promise { export async function getManifest(appName: string): Promise<{ exists: boolean, manifest?: Manifest, version?: string } | null> { try { - const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: activeSignal }) + const res = await fetch(makeUrl(`/api/sync/apps/${appName}/manifest`), { signal: getSignal() }) if (res.status === 404) return { exists: false } if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) const data = await res.json() @@ -74,7 +77,7 @@ export async function post(url: string, body?: B): Promise { try { const fullUrl = makeUrl(url) - const res = await fetch(fullUrl, { signal: activeSignal }) + const res = await fetch(fullUrl, { signal: getSignal() }) if (!res.ok) { const text = await res.text() const msg = tryParseError(text) ?? `${res.status} ${res.statusText}` @@ -125,7 +128,7 @@ export async function del(url: string): Promise { try { const res = await fetch(makeUrl(url), { method: 'DELETE', - signal: activeSignal, + signal: getSignal(), }) if (!res.ok) { const text = await res.text() diff --git a/src/cli/shell.ts b/src/cli/shell.ts index 8f83619..904325d 100644 --- a/src/cli/shell.ts +++ b/src/cli/shell.ts @@ -4,7 +4,7 @@ import * as readline from 'readline' import color from 'kleur' -import { clearSignal, get, handleError, HOST, setSignal } from './http' +import { get, handleError, HOST, withSignal } from './http' import { program } from './setup' import { STATE_ICONS } from './commands/manage' @@ -188,13 +188,12 @@ export async function shell(): Promise { // Set up AbortController for this command activeAbort = new AbortController() const signal = activeAbort.signal - setSignal(signal) // Pause readline so commands can use their own prompts rl.pause() try { - await program.parseAsync(['node', 'toes', ...tokens]) + await withSignal(signal, () => program.parseAsync(['node', 'toes', ...tokens])) } catch (err: unknown) { // Commander throws on exitOverride — suppress help/version exits if (err && typeof err === 'object' && 'code' in err) { @@ -213,7 +212,6 @@ export async function shell(): Promise { handleError(err) } } finally { - clearSignal() activeAbort = null } From f9b67c03bb6ce55f0ce76e35258a77b5f661b82d Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 28 Feb 2026 23:04:48 -0800 Subject: [PATCH 5/5] Remove caret from commander version pin --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e476a9b..7851827 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@because/forge": "^0.0.1", "@because/hype": "^0.0.2", "@because/sneaker": "^0.0.3", - "commander": "^14.0.3", + "commander": "14.0.3", "diff": "^8.0.3", "kleur": "^4.1.5" }