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
|
#!/usr/bin/env bun
|
||||||
import { program } from './setup'
|
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()
|
program.parse()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -322,4 +322,14 @@ program
|
||||||
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
.option('-v, --version <version>', 'version to rollback to (prompts if omitted)')
|
||||||
.action((name, options) => rollbackApp(name, options.version))
|
.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 }
|
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