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() +}