recipe_ssh.ts

Recipes for SSH hardening.

  • ssh_hardening builds the content of the sshd_config.d drop-in that disables root login, forces key-only auth, and restricts the login surface. AllowUsers (the deploy account) varies across hosts, and so may the MaxAuthTries and LoginGraceTime tunables (defaulting to the baseline); every other directive is the same fixed baseline. Distro-neutral (sshd_config is identical).
  • SSHD_VALIDATE is the drop-in file's validate command, so a config sshd rejects is rolled back before anything reloads onto it.
  • ssh_hardening_verify re-asserts the hardening in sshd's effective config on every plan, from the same options.
  • ssh_service_core builds the sshd service payload that reloads on a drop-in change. The service name is the one distro-varying value (systemd ssh, OpenRC sshd), so it's a parameter; the public wrappers are recipe_debian_ssh.ts's debian_ssh_service and recipe_alpine_ssh.ts's alpine_ssh_service.

Recipes are imported at config eval time inside zap's sandboxed config eval (fgro eval), which has no network, no writes, and no environment, and where the clock and randomness throw. This module is therefore a set of pure functions: each only assembles strings from its arguments — no I/O, no clock, no randomness, no imports beyond types and single_quote, itself pure. The output is deterministic for given inputs (stable ordering, trailing newline), so it stays a reliable content-addressed plan artifact.

The caller wraps ssh_hardening in a file resource, spreads a distro *_ssh_service into a service resource that reloads on it, and orders the verify after the service, both from one options object, e.g. with const hardening = {user}: zap.file({id: 'ssh-hardened-config', path: '/etc/ssh/sshd_config.d/00-hardened.conf', content: {type: 'inline', content: ssh_hardening(hardening)}, validate: SSHD_VALIDATE}), zap.service({id: 'ssh-service', ...debian_ssh_service({reload_on: ['ssh-hardened-config']}), depends_on: ['ssh-hardened-config']}) and zap.verify({id: 'ssh-hardening-valid', ...ssh_hardening_verify(hardening), depends_on: ['ssh-service']}).

view source

Declarations
#

7 declarations

ssh_hardening
#

recipe_ssh.ts view source

(options: SshHardeningOptions): string import {ssh_hardening} from '@fuzdev/zap/recipe_ssh.js';

Build the hardened sshd_config drop-in text for options.

The hardened baseline: root login disabled, password + keyboard-interactive auth off, X11 forwarding off. Only AllowUsers and the tunables (max_auth_tries, login_grace_time) come from inputs. It leaves UsePAM to the distro: Debian's and Ubuntu's stock sshd_config sets it, and Alpine's sshd is built without PAM and warns on the keyword; with password and keyboard-interactive auth off, PAM authenticates nobody either way.

sshd keeps the first value it reads for most keywords, and the stock sshd_config includes sshd_config.d/*.conf in name order before its own lines. So name the drop-in to sort first (00-hardened.conf): a later drop-in, such as a cloud image's 50-cloud-init.conf setting PasswordAuthentication yes, then can't override it.

options

returns

string

the complete drop-in text, terminated by a trailing newline.

ssh_hardening_verify
#

recipe_ssh.ts view source

(options: SshHardeningOptions): VerifyResource import {ssh_hardening_verify} from '@fuzdev/zap/recipe_ssh.js';

Build the verify payload that re-asserts the hardening in sshd's effective config for the allowed user on every plan, from the options ssh_hardening was given: root login, password auth, keyboard-interactive auth, and X11 forwarding off, AllowUsers the user and no one else, and the tunables at their values. Pass both the same options object.

sshd prints each keyword lowercase with its value, one AllowUsers entry per line and LoginGraceTime in seconds. Each assertion but one matches one whole line, fixed-string (grep -qxF), the expected line one single-quoted word, so a value is matched as text. sshd adds up AllowUsers across every file it reads, so a presence check would miss a user another drop-in allows: that one compares every allowusers line together with the one expected (test "$(…)" = '…').

It reads what sshd would run with, not the drop-in's bytes, so it catches a drop-in that sorts earlier and overrides the hardening, a hand edit, or a config that no longer parses. A failing check shows as a pending change and blocks zap deploy's preflight. Spread it into a zap.verify that depends on the ssh service.

The config is read for a connection, sshd -T -C user=<user>,host=…,addr=…,lport=22, so a Match block that applies to the allowed user (Match User, Match Group, Match All, an address list that holds every client) and re-enables password auth fails the verify; without -C, sshd prints only the global values. The client address is 192.0.2.1, a documentation address, so a Match Address block naming one real network (an office, a VPN) isn't exercised: the other users it could admit are refused by AllowUsers, but the allowed user from that network is not checked. Nor are Match LocalAddress and Match RDomain blocks: the connection names no local address or routing domain.

options

the options given ssh_hardening

returns

VerifyResource

the verify assertions.

ssh_service_core
#

recipe_ssh.ts view source

(service: string, options: SshServiceOptions): Required<Pick<ServiceResource, "service" | "enabled" | "running" | "reload_on">> import {ssh_service_core} from '@fuzdev/zap/recipe_ssh.js';

Build the sshd service payload: enabled, running, and reloaded when any of options.reload_on changes. The service name is the one distro-varying value; prefer the debian_ssh_service / alpine_ssh_service wrappers, which pass it.

zap's reload is systemctl try-reload-or-restart on systemd and rc-service --ifstarted <name> reload on OpenRC. Both distros' sshd reload runs sshd -t first and leaves the running sshd untouched when the config is invalid.

service

the service manager's name for sshd

type string

options

the ids that reload sshd (reload_on)

returns

Required<Pick<ServiceResource, "service" | "enabled" | "running" | "reload_on">>

the service payload.

SSHD_VALIDATE
#

recipe_ssh.ts view source

"sudo sshd -t" import {SSHD_VALIDATE} from '@fuzdev/zap/recipe_ssh.js';

The drop-in file's validate command: sshd -t checks the whole config, drop-ins and the body of every Match block included, so a write sshd would reject is rolled back before the service reloads onto it. An unsupported keyword is a warning to it, not an error.

SshHardeningOptions
#

recipe_ssh.ts view source

SshHardeningOptions import type {SshHardeningOptions} from '@fuzdev/zap/recipe_ssh.js';

Options for ssh_hardening and ssh_hardening_verify.

user is the per-deploy variation (the account allowed to log in); max_auth_tries and login_grace_time default to the shared baseline.

user

AllowUsers — the sole account permitted to SSH in (the deploy user). One name, so no whitespace (sshd would read each word as another user) and no , (the verify names the user in sshd -C's comma-separated connection spec).

type string

max_auth_tries?

MaxAuthTries — auth attempts before the connection is dropped. Defaults to 3.

type number

login_grace_time?

LoginGraceTime — seconds to complete authentication. Defaults to 20.

type number

SshService
#

recipe_ssh.ts view source

Required<Pick<ServiceResource, "service" | "enabled" | "running" | "reload_on">>

type Required<Pick<ServiceResource, "service" | "enabled" | "running" | "reload_on">>

import type {SshService} from '@fuzdev/zap/recipe_ssh.js';

The service fields the *_ssh_service recipes return (caller supplies id/name/depends_on).

service

Service name (interpreted by the target's service manager). Takes no reference (rejected when the config is read): it is put into the manager's command unquoted, and detection reads it as declared. So a name is one word — a letter or digit, then letters, digits, and _ . @ : - — and anything else is rejected when the config is read.

type string

enabled

Whether the service should be enabled at boot (default true).

type boolean

running

Whether the service should be running now; absent = don't manage runtime.

type boolean

reload_on

Resource ids whose change triggers a reload of this service, if it is running: try-reload-or-restart on systemd, rc-service --ifstarted <svc> reload on OpenRC (a service that can't reload fails the step).

type string[]

SshServiceOptions
#

recipe_ssh.ts view source

SshServiceOptions import type {SshServiceOptions} from '@fuzdev/zap/recipe_ssh.js';

Options for the distro *_ssh_service wrappers.

reload_on

The ids whose change reloads sshd — the hardening drop-in, at least. Required: without it the service would never pick up a config change.

type string[]

Depends on
#

Imported by
#