Documentation
¶
Overview ¶
Package sshguard renders the reviewable artifacts for one node's SSH hardening intent: an sshd drop-in, an independent nftables knock table, and an optional knockd sequence. It is deliberately dependency-free and never touches a host: the server renders text from validated state, the text goes through the existing approval gate, and the node agent runs the script the approved text produces.
Why this does not lower into internal/netguard like every other firewall surface: the netguard compiler lowers its model into network.NFTPlan, and network.GenerateNFTPlan is a fixed template that emits exactly one `table inet lattice_guard` with one `chain input` at priority 0, whose only named set is wg_peers4. It has no expression for a second named set, for `flags timeout`, for a second table, or for a custom hook priority. Port knocking is built on all four.
The independence is also a correctness requirement rather than tidiness. The knock allowlist has runtime-mutable membership: knockd adds an element every time someone knocks. lattice_guard is declarative and re-rendered whole from the model on every apply, so an allowlist living inside it would be wiped by any unrelated firewall change, disconnecting whoever had just knocked in.
Index ¶
- Constants
- func ApplyScriptFromPlan(plan string) (string, error)
- func Blocking(findings []Finding) bool
- func KnockSequenceDigest(ports []int) string
- func NewKnockSequence() ([]int, error)
- func RenderArmPlan(p Profile, nodeName string) (string, error)
- func RenderConfirmPlan(nodeID, nodeName string) (string, error)
- func SanitizeDisplayText(value string) string
- type Artifacts
- type Finding
- type Hardening
- type KnockPolicy
- type KnockSequence
- type NodeReality
- type Posture
- type Profile
- type SSHDFacts
- type SSHPosture
- type Severity
- type Stage
Constants ¶
const ( BinNFT = "/usr/sbin/nft" BinSSHD = "/usr/sbin/sshd" BinSystemctl = "/usr/bin/systemctl" BinSystemdRun = "/usr/bin/systemd-run" BinSS = "/usr/bin/ss" )
Absolute paths, not bare names. The task runner narrows PATH to /usr/bin:/bin:/usr/local/bin, which excludes every sbin directory, so a script that says `nft` works only by accident of usr-merge on some distros and fails on others. Every binary this package invokes is spelled out.
const ( // FindingOverriddenByGuard fires when the node is managed by netguard and // its lattice_guard chain would not accept a port this profile gates. // // This is the failure that is hardest to diagnose from the outside. An // accept verdict in one chain does NOT let a packet skip later chains at a // higher priority in the same hook, so a policy-drop lattice_guard silently // discards what the knock table just admitted. The operator sees a knock // that reports success followed by a connection that never opens. FindingOverriddenByGuard = "sshguard_overridden_by_guard" // FindingPortInUse fires when something already listens on the target port. // sshd -t does not catch this: binding happens at reload, not at parse. FindingPortInUse = "sshguard_port_in_use" // FindingNoReality fires when the node has never reported listeners, so the // checks above ran against nothing. It is a warning because a first-time // node is a normal state, not an error. FindingNoReality = "sshguard_no_reality" // FindingSingleWayIn fires when every management source is a single host // address AND knocking is enabled, meaning the fallback is one IP. It is a // warning: it is often exactly what an operator wants, but it should be a // decision rather than an accident. FindingSingleWayIn = "sshguard_narrow_fallback" // fallback and the node cannot actually provide one. A claimed fallback // that does not exist is worse than an admitted absence, because it is the // reason the profile was allowed to gate SSH in the first place. FindingFallbackUnavailable = "sshguard_fallback_unavailable" // FindingAssumedSSHPort fires when the profile had to fall back to tcp/22 // because the node never reported where sshd listens. Three machines in // this fleet run it on 3434, so the assumption is not safe to make // silently: gating the wrong port protects nothing and looks like success. FindingAssumedSSHPort = "sshguard_assumed_ssh_port" // FindingHardeningOnly fires when the profile has neither a management // source nor a knock policy, so Profile.GatesFirewall is false and the // apply renders no firewall at all. // // That is a legitimate profile and the safest one to apply, but it is not // what "SSH Guard" sounds like, and a fleet of them reads as protected on // the rollout screen while every one of those nodes is still reachable // from the whole internet. Say it, so it is a choice. FindingHardeningOnly = "sshguard_hardening_only" )
Plan linting exists here rather than being borrowed from netguard because the two are calibrated on opposite assumptions.
netguard's acceptsAnyPort is deliberately generous: any accept rule mentioning a management port counts as a way in, and it never looks at the rule's source. That generosity is defensible where accepts are broad, and its own comment says so. Knocking inverts the premise. Under a knock policy the normal state of the management port is closed, and acceptance is conditional on a set whose membership is empty until someone knocks. Handed a knock ruleset, netguard's lint would report a way in that does not exist yet, turning an occasional false negative into a systematic one.
So this lint asks a different question: after BOTH tables have had their say, is there still a path that does not depend on knocking working?
const ( KeyEvidenceAuthorizedKeys = "authorized_keys" KeyEvidencePubkeyAuth = "pubkey_authentication" )
Key evidence values, so the reader can tell a proven key from an inferred one.
const ( // DropInPath is the only sshd file this package writes. Writing a drop-in // rather than editing sshd_config means a rollback is a file removal, and // an operator reading the host can see exactly which lines Lattice owns. // // The name sorts first on purpose. sshd takes the FIRST value it reads for // a keyword, and Include comes near the top of sshd_config, so a drop-in // that sorts earlier wins outright. At the old `60-` this file lost to the // two files nearly every cloud image ships: `50-cloud-init.conf`, which // carries whatever `ssh_pwauth` was set to and is rewritten whenever // cloud-init re-runs, and `50-redhat.conf`. Eighteen of this fleet's // thirty-three nodes have one of those declaring an auth keyword; today // they all happen to say `no`, which is the only reason the loss was // invisible. One provider image also ships // `00-permit-root-password-auth.conf`, which turns root password login // back on and beat the guard outright. // // A guard that can be silently overruled by a file the platform rewrites // is not a guard, so it goes first and the apply's effectiveness check // stays as the proof. DropInPath = "/etc/ssh/sshd_config.d/00-lattice-guard.conf" // LegacyDropInPath is where this package wrote before the ordering fix. // Every arm removes it, and every revert puts it back, so a node that was // hardened under the old name is migrated by the next apply rather than // left with two files that both claim to own the same settings. LegacyDropInPath = "/etc/ssh/sshd_config.d/60-lattice-guard.conf" // KnockTable is deliberately not lattice_guard. See the package comment. KnockTable = "lattice_knock" // KnockdSection and KnockdPreviousSection name the two stanzas knockd.conf // can carry: the sequence in force, and during a rotation the one being // retired. The confirm script removes the second by name. KnockdSection = "openSSH" KnockdPreviousSection = "openSSH-previous" // KnockAllowedSet is where a knock opens the gate; KnockPreviousSet is // where the sequence being rotated out opens it. Two sets, not one, so // the confirm can tell which sequence admitted the operator. KnockAllowedSet = "allowed" KnockPreviousSet = "allowed_previous" KnockNFTPath = "/etc/lattice/sshguard/knock.nft" KnockdConf = "/etc/knockd.conf" StateDir = "/etc/lattice/sshguard" // RevertUnit is the transient timer that undoes an unconfirmed change. It // really does revert, so the name is accurate. RevertUnit = "lattice-sshguard-revert" // FirewallUnit restores the gate at boot after a confirm. It is a separate // name from RevertUnit because it does the opposite thing: an operator // reading `systemctl list-unit-files` on a confirmed node should not find // something called "revert" enabled and have to read its ExecStart to learn // that it actually reinstalls the firewall. FirewallUnit = "lattice-sshguard-firewall" // LegacyBootUnit is the name FirewallUnit shipped under before it was // separated from RevertUnit. Nodes confirmed under the old name still have // it enabled, so every path that installs or removes the boot unit also // removes this one; otherwise a re-armed node ends up with two units // loading the same ruleset and only one of them documented. LegacyBootUnit = "lattice-sshguard-revert-boot" // KnockHookPriority puts the knock chain ahead of the ordinary filter // chains so its verdict is reached first. It does NOT let an accepted // packet skip later chains in the same hook, which is why a node carrying // both this table and a policy-drop lattice_guard must open the management // port in both. Callers enforce that; this package only renders. KnockHookPriority = -10 // MinConfirmWindowSec is the floor for the human confirmation window. The // window exists so an operator can prove they can still get in before the // change is made permanent, and a minute is not enough time to open a // terminal, knock, and log in. MinConfirmWindowSec = 120 // MaxConfirmWindowSec caps how long a node can sit armed. The task runner // silently falls back to a 30s timeout when a task asks for more than ten // minutes, but the revert timer is a systemd transient unit that outlives // the task, so this bound is about exposure, not the runner. MaxConfirmWindowSec = 3600 DefaultConfirmWindowSec = 900 // KnockSequenceLen is fixed rather than configurable. Two ports is a // guessable pair under a port scan; more than three adds latency to every // login for no meaningful entropy gain over 3 * 16 bits. KnockSequenceLen = 3 )
const TimerArmedAfterAllLine = "lattice sshguard: no authorized key found in a file sshd reads; arming the automatic revert after all"
TimerArmedAfterAllLine is what a durable arm prints when the host showed no authorized key and the script armed the revert timer anyway. The server matches it in the task output, so it is a constant rather than prose.
Variables ¶
This section is empty.
Functions ¶
func ApplyScriptFromPlan ¶
ApplyScriptFromPlan renders the bounded shell that puts an approved plan on a host. It derives everything from the plan text, so the bytes that were reviewed are the bytes that land.
Ordering in the arm script is load-bearing, not stylistic:
- Snapshot, then write the revert script, then arm the timer, all BEFORE the first change. A failure at any later point therefore has a working undo already on disk and already scheduled.
- sshd gains the new port while keeping the old one. Adding before removing is what makes this stage carry no lockout risk of its own.
- knockd starts BEFORE the firewall goes up. The gate and the thing that opens the gate must not be applied in the other order, or the window between them is a window where nobody can get in.
- The nftables table lands last, and its first rule accepts established connections, so the session watching the apply is not cut by it.
func KnockSequenceDigest ¶
KnockSequenceDigest is the sha256 of a sequence in its canonical "p1,p2,p3" form. It lets a plan request name the sequence it rotates from without carrying the ports: the reveal hands the digest over with the ports, the rotation request hands it back, and the server refuses to rotate from a sequence other than the one it holds as installed. It is not a secret substitute: three ports in a 40000-wide range are brute-forceable from the digest, so it is returned only where the ports themselves already are.
func NewKnockSequence ¶
NewKnockSequence draws a fresh sequence from crypto/rand.
It is drawn rather than derived from the node id on purpose. A derived sequence is only as secret as the derivation, and the derivation lives in a repository; anyone who reads it and knows a node id knows that node's sequence. Drawn ports are stored with the profile and are secret the way a credential is.
func RenderArmPlan ¶
RenderArmPlan produces the reviewable document for the stage that makes every change and arms the automatic revert.
func RenderConfirmPlan ¶
RenderConfirmPlan produces the document for the stage that cancels the pending revert. It carries no artifacts because it writes no files: its whole effect is to stop a timer, and it should be trivially reviewable.
func SanitizeDisplayText ¶
SanitizeDisplayText strips anything that could add a line to a rendered document. Display strings such as a node's name come from other subsystems and are interpolated into the plan header, which the parser reads line by line; a newline there is a way to add a header key nobody reviewed.
It sanitizes rather than rejects because a node name is cosmetic: refusing to plan because someone put a control character in a label would be a worse failure than showing the label with the character removed.
Types ¶
type Artifacts ¶
type Artifacts struct {
Stage Stage
NodeID string
SSHPort int
KeepLegacyPort bool
ConfirmWindowSec int
// Durable is the plan's claim that this arm needs no confirm: it installs
// no firewall, changes no port, and the node showed a key path in at plan
// time. The arm script re-checks the key on the host before it honours
// the claim, and ParseApprovalPlan refuses the claim on any plan that
// carries a firewall or a port.
Durable bool
SSHDDropIn string
KnockNFT string
KnockdConf string
GatedPorts []int
}
Artifacts is what a plan parses back into: the literal file contents plus the few scalars the script needs to sequence itself.
func ParseApprovalPlan ¶
ParseApprovalPlan reads back exactly what RenderArmPlan or RenderConfirmPlan wrote. It is strict: an unknown stage, a missing artifact, or a header it cannot read is an error rather than a default, because every one of those would produce a script that does something other than what was reviewed.
type Finding ¶
type Finding struct {
Code string `json:"code"`
Severity Severity `json:"severity"`
Message string `json:"message"`
}
func LintProfile ¶
func LintProfile(p Profile, r NodeReality) []Finding
LintProfile checks a profile against what the node actually looks like.
type Hardening ¶
type Hardening struct {
// LoginGraceTimeSec is the single highest-value field here. The sshd
// default is 120s, and brute-force clients hold that window open to
// occupy connection slots; dropping it to ~20s removes the noise more
// cheaply than any ban list.
LoginGraceTimeSec int
MaxAuthTries int
// MaxStartups is a raw "start:rate:full" triple because sshd's own syntax
// is the clearest expression of it.
MaxStartups string
PasswordAuth bool
KbdInteractiveAuth bool
PermitRootLogin string
X11Forwarding bool
AllowAgentForwarding bool
}
Hardening is the sshd side of a profile. Every field maps to exactly one sshd_config keyword; there is no free-form passthrough, because a profile that can write arbitrary sshd directives is a profile that can lock a fleet out in ways this package cannot reason about.
func DefaultHardening ¶
func DefaultHardening() Hardening
DefaultHardening is what the one-click path applies. It is the configuration verified on gomami-hkg on 2026-08-20, not a guess.
type KnockPolicy ¶
type KnockPolicy struct {
Ports []int
SeqTimeoutSec int
// OpenFor is an nftables timeout literal such as "12h". Membership expires
// on its own, which is why there is no close sequence: forgetting to close
// the door is a failure mode this design does not have.
OpenFor string
// PreviousPorts is the sequence being rotated out, when this arm is a
// rotation. It is rendered as a second knockd stanza that opens its own
// nftables set, so the sequence the operator already holds keeps opening
// the gate until the confirm approval retires it. Without this a rotation
// cut over the moment knockd restarted, and an operator who could not
// produce confirm evidence from a source the node sees had nothing but
// the revert timer between him and a lockout.
PreviousPorts []int
}
KnockPolicy describes the port sequence that opens the SSH port.
The sequence is UDP and the package refuses anything else. This is not a preference: knocking a TCP port with no listener makes the kernel retransmit the SYN, and a packet capture on gomami-hkg showed nine retransmissions to a single port. By the time the second port's packet arrives, knockd's state machine has seen the same port repeatedly and never advances past stage one. One UDP datagram is one datagram.
type KnockSequence ¶
type KnockSequence struct {
Ports []int
SeqTimeoutSec int
// OpenFor is the nftables set timeout the knock installs, as written
// (for example "12h"). Empty when the start_command does not carry one.
OpenFor string
// PreviousPorts is the sequence a rotation kept alive beside Ports. It is
// on the node only until the confirm retires the stanza, so a reader has
// to pair it with whether the arm was confirmed.
PreviousPorts []int
}
KnockSequence is what a rendered knockd.conf says about how to open the port.
func ParseKnockdConf ¶
func ParseKnockdConf(conf string) (KnockSequence, error)
ParseKnockdConf reads the sequence back out of a knockd.conf rendered by RenderKnockdConf.
It is strict in the same way ParseApprovalPlan is strict. A conf whose sequence line is missing, malformed, not UDP, or the wrong length is an error rather than a partial answer, because a partial answer here is a sequence an operator would knock and then be unable to explain the failure of.
func (KnockSequence) KnockCommand ¶
func (k KnockSequence) KnockCommand(address string, sshPort int) string
KnockCommand renders the shell an operator runs to open the port.
It is the same command the arm plan prints, kept in one place so the console and the plan cannot disagree about how to knock. The payload byte is not decoration: an empty datagram advances knockd to stage one and no further, so `nc -u -z` looks like it worked and leaves the port shut.
type NodeReality ¶
type NodeReality struct {
// Reported is false when the node has never sent a reality snapshot.
Reported bool
// ListeningTCPPorts is what the node currently has bound.
ListeningTCPPorts []int
// ManagedByNetGuard is true when netguard has a lattice_guard table in
// front of this node: the binding is managed and a table has been applied
// from it, or the node reports one. A binding record on its own is not
// enough; an observe-only binding describes nothing on the node.
ManagedByNetGuard bool
// GuardAcceptedTCPPorts is what that guard ruleset would accept. Only
// meaningful when ManagedByNetGuard is true.
GuardAcceptedTCPPorts []int
// GuardAcceptsAllTCP is set when the guard has a rule that accepts every
// TCP port (an any-protocol accept, or a tcp accept with no port list).
// It is a flag rather than a 65535-entry slice because this is assembled on
// a request path.
GuardAcceptsAllTCP bool
// GuardPolicyDrop reports whether the guard chain's policy is drop. A guard
// with an accept policy cannot override this table's accepts, so the
// override check does not apply.
GuardPolicyDrop bool
// SSHPorts are the ports a shell daemon is observed bound to. The caller
// copies these into the profile so the gate covers where sshd is rather
// than where it is assumed to be.
SSHPorts []int
// TerminalAvailable is true when this node can currently give an operator a
// shell without SSH: it is online and its agent reports the terminal
// capability. Both halves matter. A capability flag on a node that stopped
// reporting is a fallback on paper.
TerminalAvailable bool
}
NodeReality is the subset of observed node state this lint reasons about. It is a plain struct rather than the model type so this package keeps no dependency on the server's model or store.
type Posture ¶
type Posture string
Posture is what a node's own sshd says about who can log in. It is derived from the facts the agent reports (`sshd -T`, read as root), never from what the control plane remembers doing to the node.
The distinction is the whole reason this exists. The SSH Guard board used to colour a row by the disposition of the last arm approval: an arm that applied and then reverted because nobody confirmed inside the window read as red, and so did an arm an operator had rejected weeks earlier. On the fleet that produced this, every one of those red rows was a node whose sshd had password authentication off and the operator's key installed. The approval history said "failed"; the node said "secure". The node is right, and the approval is history.
const ( // PostureSecured: password authentication is off, root cannot log in by // password, and a key path is present. This is the state the fleet is // meant to be in, and it is a calm state however the last arm ended. PostureSecured Posture = "secured" // PosturePasswordOpen: sshd accepts passwords. Whatever else is set, the // brute force in the auth log can succeed here. PosturePasswordOpen Posture = "password_open" // PosturePartial: passwords are off, but either root may still log in // with one (PermitRootLogin yes, which sshd honours over // PasswordAuthentication for the root account on some builds) or the // facts show no key path, so key-only cannot be claimed. PosturePartial Posture = "partial" // PostureUnknown: the node has never reported sshd facts, or its agent // could not read them. Nothing is claimed either way. PostureUnknown Posture = "unknown" )
type Profile ¶
type Profile struct {
NodeID string
Name string
// SSHPort is the port sshd will listen on in addition to 22. Zero means
// the port is left alone and only the hardening and firewall apply.
SSHPort int
// KeepLegacyPort keeps sshd listening on 22. The default is true and the
// reason is in RenderKnockRuleset: 22 is shrunk to the management sources
// and knocked-open sources rather than closed, which removes the brute
// force without removing a way in.
KeepLegacyPort bool
Hardening Hardening
// Knock nil disables knocking entirely; the firewall then only shrinks the
// ports to MgmtSources.
Knock *KnockPolicy
// GatePorts overrides what the gate covers.
//
// The escape hatch, and deliberately a small one. The ordinary path derives
// the ports from what sshd reports, which is right for a normal host; this
// exists for the ones that are not, without teaching the product about each
// of them. Set it and the derivation is skipped entirely, including the
// fallback to 22.
GatePorts []int
// ExistingSSHPorts are the ports sshd is observed listening on, from the
// node's own report. The gate covers these rather than a guess: three
// machines in this fleet run sshd on 3434, and a profile that gates 22 on
// one of them protects nothing while reporting success.
//
// Empty means the node has not reported, and the profile falls back to 22
// with a warning rather than silently gating the wrong thing.
ExistingSSHPorts []int
// MgmtSources are CIDRs that reach SSH without knocking, forever.
MgmtSources []string
// Address is the node's public address, used only to print a knock command
// an operator can copy. It is reported by the agent, so it is validated as
// an IP literal before it reaches the document and dropped if it is not
// one: a plan is read by a human deciding whether to approve, and text a
// peer chose has no business shaping it.
Address string
// OutOfBandFallback says the operator's fallback is a path that does not
// use SSH at all, which on this fleet means the node's Lattice terminal.
//
// It exists because the obvious alternative turned out to be worse. An IP
// allowlist looks like a safety net and is only as good as the address
// staying put: the address written into the reference node's allowlist went
// stale within hours of being written, and a node behind a proxy sees a
// source that changes with the route. A fallback that silently expires is
// more dangerous than no fallback, because nobody re-checks it.
//
// The Lattice terminal does not care where the operator is. It is verified
// against the node's reported capability at plan time rather than trusted,
// because a profile claiming a fallback that is switched off is exactly the
// failure this field is supposed to prevent.
OutOfBandFallback bool
// KeyAccessObserved says the node's own sshd facts show a key path in
// (see DerivePosture). The server sets it from the last reality report;
// it is never taken from the request, because it decides whether the
// arm gets a revert timer at all.
KeyAccessObserved bool
ConfirmWindowSec int
}
Profile is one node's complete SSH guard intent.
func (Profile) Durable ¶
Durable reports whether this profile's arm is permanent on its own, with no confirm and no automatic revert.
The revert exists for one risk: the operator changes how the node is reached and cannot get back in. A hardening-only profile changes no path in or out (GatesFirewall is false and SSHPort is zero), and on a node whose sshd already shows a key path in, turning password authentication off takes nothing away from anyone holding that key. Arming a timer there produced the fleet's worst state: an arm that applied, a window nobody was watching, and a revert that undid a correct change and left the row red. The apply still verifies the key on the host before it skips the timer, so this is the plan's claim and the host's check together, never the plan alone.
Anything that installs the knock firewall keeps the confirm-or-revert dance, because that is the genuine lockout risk. So does any change to the port sshd listens on, with or without 22 kept: the observed key says the holder can authenticate, not that the new port is reachable from outside. A security group, a NAT that forwards only 22, or a middlebox leaves sshd listening locally on a port nobody can get to, the script's own listen check passes, and a migration that dropped 22 has no way back. The confirm is how the operator proves the new path from where they actually sit.
func (Profile) GatedPorts ¶
GatedPorts is the set of TCP ports the knock table guards, in rule order.
ExistingSSHPorts is where the ports sshd is ACTUALLY on come from, and it matters more than it looks. Assuming 22 was wrong on this fleet: measuring it found three machines whose sshd listens on 3434, where gating 22 installs a door nobody uses and leaves the real one open while reporting success. That is the same failure as writing a drop-in and not checking it took effect, one layer up.
The ports sshd is on are always gated, whether or not the profile moves it, because an ungated listening port makes every other control cosmetic: the brute force simply keeps using whatever is open.
func (Profile) GatesFirewall ¶
GatesFirewall reports whether this profile installs an nftables gate at all.
A profile with neither a management source nor a knock policy expresses "harden sshd, leave reachability alone", and that is the shape a fleet-wide rollout should use: it changes no path in or out, so it carries no lockout risk of any kind.
Inferring it rather than taking a flag closes a hole rather than adding a feature. The combination used to render a chain whose only rule for the SSH port was `counter drop`, with no accept anywhere: a guaranteed, permanent lockout for anyone who confirmed it without testing first. Treating the same input as "no firewall" makes the dangerous configuration unreachable instead of merely refused.
func (Profile) RenderKnockRuleset ¶
RenderKnockRuleset produces the independent nftables table.
Shape notes that are load-bearing rather than stylistic:
- `ct state established,related accept` is the first rule. It is what makes applying this ruleset safe from inside an SSH session, and therefore what makes an automatic revert timer possible at all: the operator watching the apply does not get cut by the apply.
- The table is created and deleted before being defined so the file is idempotent; re-applying it replaces rather than merges.
- policy accept, not drop. This table's job is to gate specific ports, not to be the node's firewall. A policy-drop chain here would silently become a second, competing default-deny alongside lattice_guard.
func (Profile) RenderKnockdConf ¶
RenderKnockdConf produces the knockd sequence definition.
The sequence is UDP because a TCP knock does not survive the kernel's own retransmission: a capture on gomami-hkg showed nine SYN retransmissions to a single unanswered port, which advances knockd's state machine on the wrong port and strands it at stage one forever.
Known limitation, stated rather than hidden: knockd opens the v4 set only. An operator arriving over IPv6 must be in mgmt6, which Validate already requires to be non-empty in spirit by requiring a management source at all.
func (Profile) RenderSSHDDropIn ¶
RenderSSHDDropIn produces the only sshd file this package owns.
Both ports are listed while KeepLegacyPort holds. Adding a port before taking one away is the entire reason the arm stage carries no lockout risk: at every instant during the change, every path that worked before still works.
type SSHDFacts ¶
type SSHDFacts struct {
PasswordAuthentication bool
PubkeyAuthentication bool
// PermitRootLogin is the literal value sshd prints: yes, no,
// without-password (what -T prints for prohibit-password), or
// forced-commands-only.
PermitRootLogin string
// AuthorizedKeys is how many authorized keys the node reported across the
// files sshd reads, or nil when the report carries no count. Today's
// agent reports none, so nil is the normal value; when a count arrives it
// takes precedence over PubkeyAuthentication as the key evidence, because
// pubkey auth being enabled with no key on disk is not a way in.
AuthorizedKeys *int
}
SSHDFacts is the subset of the agent's sshd report that posture reasons about. It is a plain struct rather than the model type so this package keeps no dependency on the server's model.
type SSHPosture ¶
type SSHPosture struct {
State Posture `json:"state"`
// KeyAccess reports whether the facts show a key path in. KeyEvidence
// says which fact backed it.
KeyAccess bool `json:"key_access"`
KeyEvidence string `json:"key_evidence,omitempty"`
Reason string `json:"reason"`
}
SSHPosture is the derived view. Reason is the sentence the console shows; it is written here so the API and the page cannot disagree.
func DerivePosture ¶
func DerivePosture(facts *SSHDFacts) SSHPosture
DerivePosture reads the facts and says what they add up to. nil facts is the honest input for a node that has not reported, and it yields unknown rather than a guess in either direction.
type Stage ¶
type Stage string
Stage names the two halves of an apply. A profile reaches a host through StageArm, which makes every change and arms an automatic revert, and is made permanent by StageConfirm, which cancels it. Splitting them is the whole safety property: an operator who cannot get back in after StageArm does nothing, and the node returns to its previous state on its own.