Add SSH shell and NSS guest user support
This commit is contained in:
parent
68274d8651
commit
dc570cc6e9
87
scripts/nss/libnss_toes.c
Normal file
87
scripts/nss/libnss_toes.c
Normal 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
90
scripts/setup-ssh.sh
Executable 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."
|
||||
|
|
@ -1,4 +1,13 @@
|
|||
#!/usr/bin/env bun
|
||||
import { program } from './setup'
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
231
src/cli/shell.ts
Normal 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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user