Add SSH shell and NSS guest user support

This commit is contained in:
Chris Wanstrath 2026-02-27 07:25:46 -08:00
parent 68274d8651
commit dc570cc6e9
5 changed files with 428 additions and 1 deletions

87
scripts/nss/libnss_toes.c Normal file
View File

@ -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 <nss.h>
#include <pwd.h>
#include <string.h>
#include <errno.h>
#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);
}

90
scripts/setup-ssh.sh Executable file
View File

@ -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."

View File

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

View File

@ -322,4 +322,14 @@ program
.option('-v, --version <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 }

231
src/cli/shell.ts Normal file
View File

@ -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<string[]> {
const now = Date.now()
if (appNamesCache.length > 0 && now - appNamesCacheTime < APP_CACHE_TTL) {
return appNamesCache
}
try {
const apps = await get<App[]>('/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<void> {
const apps = await get<App[]>('/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<void> {
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()
}