Documentation
¶
Overview ¶
Package node lets one panel configure tunnels on servers it manages.
The problem it solves is the setup itself. A tunnel has two ends and every field that matters has to agree on both — the token, the port, the transport, the MTU, the forged source address. Doing that by hand means configuring Iran, then opening a second terminal, logging into the far server as root, and doing it again. Half the support traffic this project generates is one end disagreeing with the other because a value was mistyped on the second pass.
So the panel writes both ends.
How it reaches the far server ¶
Over that server's own SSH, with the address and root login the operator gives it. The panel dials out; nothing listens for this on either side beyond the sshd that was already running.
One command runs there — Execute, reached through `hashem node exec` — and it performs a single operation from the list in ops.go and refuses anything else. There is no state on the far machine that belongs to being managed: no service, no config, nothing to clean up when it leaves the fleet.
What this costs, said plainly ¶
The panel holds root on every server in the fleet. Anyone who takes the panel takes the fleet with it.
That is a real cost and it was not always paid. The design this replaced had the far server run an agent that dialled the panel and accepted a fixed list of operations, so the panel held an authorisation rather than an identity: a compromised panel could misconfigure tunnels, which is bad and recoverable, but could not read a file or run a command of its choosing.
It was the better shape and it was worse in practice, for reasons that had nothing to do with security:
- Every server needed its own inbound port on the panel, opened in the firewall and not colliding with anything.
- Setting one up meant pasting a command on that machine, so the operator left the panel for a terminal on a server they might only have a password for.
- The agent was a third service to install, keep running and debug — and when it was not running, the panel just said the server was offline.
Each of those was a way for a server to be listed and unreachable anyway, and between them they accounted for most of what went wrong with the feature. SSH is already there, already authenticated, already how the machine is administered.
The operations list still exists and is still enforced by Execute, but it is no longer a security boundary — anything holding the panel's credentials can open a shell. It is kept because it is the right shape for the protocol: one request, one answer, and a refusal for anything the far side does not do.
The host key ¶
Trust on first use. The first connection records the SHA-256 of the key the server presents and every one after that must match, which is the same bargain as typing "yes" at ssh's own prompt. Accepting any key instead would mean anything that can answer on that address gets a root shell, and the panel would never say a word about it.
A key that changes is reported, not accepted: either the server was rebuilt, or something is answering in its place, and those are not for a panel to decide between.
Applying a configuration is one verb ¶
Create and edit are the same operation. The panel sends the complete desired state of a tunnel and the far server reconciles: write it, restart, and keep what was there before. It is not two verbs because the second would have to answer "what if it does not exist yet" and the first "what if it does", and both answers are the same code.
The rollback that makes this safe over a network already existed for the local case — applySpec writes, restarts, waits, and puts the old file back if the tunnel does not come up — and matters far more here. A bad push to a machine on another continent that leaves it unable to start is the failure with no recovery path, except that this channel does not run over the tunnel: a tunnel that is down does not take away the ability to fix it.
Index ¶
- Constants
- Variables
- func Fingerprint(k ssh.PublicKey) string
- func NoteFingerprint(name, fp string) error
- func NoteInfo(name string, info Info) error
- func RecordTerminalAudit(nodeName, command string)
- func Remove(name string) error
- func SaveStore(s Store) error
- func SetCredentials(name, host string, sshPort int, user, password string) error
- type ApplyRequest
- type ApplyResult
- type ErrHostKeyChanged
- type ErrOffline
- type Info
- type LogsRequest
- type LogsResult
- type NameRequest
- type Node
- type PTYSession
- type ReceiveRequest
- type Request
- type Response
- type Runner
- type SSHRunner
- func (r *SSHRunner) Call(name, op string, body, out any) error
- func (r *SSHRunner) Close()
- func (r *SSHRunner) ExecRaw(name, command string) (string, error)
- func (r *SSHRunner) Forget(name string)
- func (r *SSHRunner) Install(name string) (string, error)
- func (r *SSHRunner) IsOnline(name string) bool
- func (r *SSHRunner) Reachable(name string) (bool, string)
- func (r *SSHRunner) Upgrade(name string) (string, error)
- type SSHTarget
- type Store
- type TerminalAuditEntry
- type TunnelState
Constants ¶
const ( // OpHello asks a node to describe itself. The panel calls it on every // reconnect, because an address, a kernel or a version can change between // one connection and the next and a stale fact on a fleet screen is worse // than no fact. OpHello = "hello" // OpApply sends the complete desired state of one tunnel. It creates the // tunnel if it is not there and rewrites it if it is; see ApplyRequest. OpApply = "apply" // OpList asks which tunnels the node has, and whether they are running. OpList = "list" // OpStatus asks about one tunnel by name. OpStatus = "status" // OpSettings reads back one tunnel's settings as the panel's own edit form // would show them. // // It exists because an edit rebuilds the far end from the mirror of this // end, and the mirror carries only what the two ends must agree on. The // answers that belong to the far end alone — its outbound proxy, the // interface or source address it dials from, its backup addresses — were // given once, when the tunnel was paired, and are held nowhere on this // side. Rebuilding without them would quietly drop them on every edit, so // the panel asks the node what it currently has and lays the edit over it. OpSettings = "settings" // OpLogs returns the far end's journal for one tunnel. // // A tunnel is one thing in two places and its log is not: half of what went // wrong is on the other machine, and reading it meant logging into that // machine — which is the second pass the whole fleet feature exists to // remove. The panel asks for it the same way it asks for anything else. OpLogs = "logs" // OpLinkTest measures the path one of this server's tunnels dials out over. // // It exists because the measurement can only be taken where the dialling // happens, and that is usually not the machine the panel runs on. An Iran // panel manages the fleet and holds the listening half of every reverse // tunnel; the half with a peer address to measure is on a server in that // fleet. Without this the panel could only say "not from here", which is // true and useless when it has a shell on the machine where it can be done. OpLinkTest = "linktest" // OpDelete removes one tunnel from this server: its service, its unit and // its configuration, exactly as the CLI's delete does. // // It did not exist, deliberately: deleting a tunnel on the panel's own // machine is not consent to deleting one somewhere else, and a delete has // no undo. What changed is not that reasoning but who acts on it — the // panel now asks about the far end as its own question, separately from // the one it asks about this end, and only sends this when the answer was // yes. An operator who says nothing still gets what they got before: this // end gone, the other end named and left alone. OpDelete = "delete" // OpStart, OpStop and OpRestart drive one tunnel's service. Nothing is // written by any of them. // // A tunnel is one tunnel in two places, and its state is one state: an end // stopped on its own is not a stopped tunnel, it is a tunnel with one half // dialling something that will never answer, retrying for as long as anyone // leaves it. So the card's buttons reach both ends, the same way its Edit // does. OpStart = "start" OpStop = "stop" OpRestart = "restart" // OpReceive runs the speed test's receiver for a bounded time. // // Adding to this list is the one change in this package that has to be // argued for, so: a speed test measures by pushing bytes at a sink on the // other server, and until now somebody had to go and start that sink by // hand — the panel's own error said so, and pointed at a CLI menu on a // machine the operator was not sitting at. On a managed server that is // exactly the second pass this feature exists to remove. // // What it grants is narrow. It opens a listener on one port for a few // seconds and discards everything that arrives; it reads nothing, writes // nothing, and closes itself whether or not anyone connects. The port is // one of the tunnel's own backend ports, which the panel already knows // because it wrote the configuration. OpReceive = "receive" )
The operations a node will perform. This list is the security boundary: a node executes these and refuses everything else, so widening it is the one change in this package that has to be argued for rather than made.
Note what is absent. There is no operation that runs a command, reads a path, installs a binary, or removes a tunnel. The first three would turn the channel into a remote shell. The fourth is left out for a different reason — applying a configuration is reversible, because the previous one is filed and a failed apply rolls back, and deleting is not. A tunnel that should not exist can be stopped from here and removed on the machine.
Variables ¶
var DefaultSSHPool = newPool()
DefaultSSHPool is the shared pool used by runners.
var ErrNeedsInstall = errors.New("hashem must be installed on that server")
ErrNeedsInstall means the far server cannot answer this panel because the Hashem on it is missing or too old. Both are fixed the same way, by installing over the same SSH connection, so both carry this.
var StorePath = app.ConfigDir + "/nodes.json"
StorePath is where the panel keeps its side of the fleet: the servers it manages and how to reach them.
It sits beside webui.json and is written with the same permissions and for the same reason — a root password for another machine is in here, so it is root-only and never world-readable, even briefly.
var (
TermAuditPath = app.ConfigDir + "/node-terminal-audit.json"
)
Functions ¶
func Fingerprint ¶
Fingerprint is the SHA-256 of a host key, in ssh's own display form.
func NoteFingerprint ¶
NoteFingerprint records the host key a server presented, the first time it answered. It refuses to overwrite one that is already there: a key that changed is a thing to report, not to accept quietly.
func RecordTerminalAudit ¶
func RecordTerminalAudit(nodeName, command string)
RecordTerminalAudit appends an audit record for a node command.
func Remove ¶
Remove takes a server out of the fleet. Its tunnels keep running there, because they are systemd services on that machine and have nothing to do with this panel being able to reach it.
Types ¶
type ApplyRequest ¶
type ApplyRequest struct {
Kind string `json:"kind"` // "reverse" or "direct"
Tunnel *manage.NewTunnel `json:"tunnel,omitempty"`
Direct *manage.NewDirectTunnel `json:"direct,omitempty"`
}
ApplyRequest is the complete desired state of one tunnel.
It carries the panel's own setup form rather than a rendered config file. The panel could render the TOML and send that — it is the same binary at both ends — but then the node would be writing a file it had not checked, and every validation the form path performs (the port that is already taken, the preset that does not suit the transport, the certificate a WSS server needs) would happen on the wrong machine, against the wrong machine's facts.
Sending the form means the node builds its own config from it, so those checks run where their answers are true.
type ApplyResult ¶
type ApplyResult struct {
Service string `json:"service"`
Active bool `json:"active"`
// Created distinguishes the tunnel that was made from the one that was
// rewritten, which is the difference between "added" and "updated" in the
// panel's own wording.
Created bool `json:"created"`
}
ApplyResult says what an apply did.
type ErrHostKeyChanged ¶
type ErrHostKeyChanged struct {
Name, Had, Got string
}
ErrHostKeyChanged is returned when a server presents a different host key than the one recorded for it.
func (ErrHostKeyChanged) Error ¶
func (e ErrHostKeyChanged) Error() string
type ErrOffline ¶
type ErrOffline struct {
Name string
Why string
// Err is what actually went wrong, kept so a caller can ask what kind of
// unreachable this is. It used to be flattened into Why and thrown away,
// which left the add handler matching on the words in the sentence — and
// a handler that keys on prose breaks silently the moment the prose is
// reworded, with no build error and no failing test to say so.
Err error
}
ErrOffline is what every call returns when a server cannot be reached. The wording is the operator's, not the protocol's: "offline" is a fact they can act on, where "no session" is not.
func (ErrOffline) Error ¶
func (e ErrOffline) Error() string
func (ErrOffline) Unwrap ¶
func (e ErrOffline) Unwrap() error
type Info ¶
type Info struct {
Name string `json:"name,omitempty"` // the node's name in the panel
Hostname string `json:"hostname,omitempty"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
// What the fleet card says about the machine itself.
//
// OS above is the kernel's word for the platform — "linux" — which says
// nothing an operator did not already know. Distro is what the machine
// calls itself, and Uptime is how long it has been up, which together are
// the two facts worth looking at a server's card to read when nothing is
// wrong.
Distro string `json:"distro,omitempty"`
Uptime string `json:"uptime,omitempty"`
// What the machine is doing right now.
//
// The card showed what a server is and not what it is doing, so a node
// under load looked exactly like an idle one. These are read on the far
// machine when the panel asks, which is the only place they can be read at
// all — this panel cannot see another server's processor.
CPUPercent float64 `json:"cpuPercent,omitempty"`
CPUCores int `json:"cpuCores,omitempty"`
MemPercent float64 `json:"memPercent,omitempty"`
MemUsed uint64 `json:"memUsed,omitempty"`
MemTotal uint64 `json:"memTotal,omitempty"`
// Where the machine is, as the machine itself sees it.
//
// The panel used to work this out by looking up the peer's address, and on
// an Iran server that lookup goes to providers the route does not reach —
// so it returned nothing, and every card showed a dot where a flag belongs
// and a dash where a location belongs. A managed server is outside that
// route by definition, so it can answer for itself, and the panel is told
// rather than guessing.
Country string `json:"country,omitempty"` // ISO code, for the flag
City string `json:"city,omitempty"`
ISP string `json:"isp,omitempty"`
}
Info is what a node reports about itself.
type LogsRequest ¶
type LogsRequest struct {
Name string `json:"name"`
Lines int `json:"lines,omitempty"` // 0 means the usual number
}
LogsRequest asks for one tunnel's journal on the far server.
type LogsResult ¶
LogsResult is what came back.
type NameRequest ¶
type NameRequest struct {
Name string `json:"name"`
}
NameRequest addresses one tunnel.
type Node ¶
type Node struct {
// Name is what the operator called it and how every other part of the
// panel refers to it. It is fixed when the server is added: renaming would
// strand the tunnels already pointing at it.
Name string `json:"name"`
// Host is its address, and SSHPort the port sshd answers on. The panel
// dials out to these; nothing is opened here.
Host string `json:"host"`
SSHPort int `json:"sshPort,omitempty"` // 0 means 22
// User and Password are the login. Root, in practice: the panel installs
// services and writes into /etc on that machine, which is what managing it
// means.
//
// Password is in memory only. What goes on disk is Sealed, and the
// difference is about the backup archive rather than about this machine —
// see seal.go. A legacy registry that still has a plaintext "password" is
// read from this field and re-sealed on its next save.
User string `json:"user"`
Password string `json:"password,omitempty"`
// Sealed is Password encrypted with a key that is not in the backup.
Sealed string `json:"password_sealed,omitempty"`
// Fingerprint is the SHA-256 of the host key this server presented the
// first time it answered. Every connection after that must match it. Empty
// until the first successful call.
Fingerprint string `json:"fingerprint,omitempty"`
Added int64 `json:"added"` // unix seconds
LastSeen int64 `json:"lastSeen,omitempty"` // unix seconds
// Info is what the server last said about itself. It is stored rather than
// asked for on demand so the fleet screen can draw a server that is down.
Info Info `json:"info,omitempty"`
}
Node is one managed server.
func Add ¶
Add records a server the panel will manage over SSH.
Nothing is contacted here. Whether the address answers, whether the password is right and whether Hashem is installed there are all questions with the same answer — try it — and the caller does that once, so a failure is reported as itself rather than as four checks that each half-worked.
type PTYSession ¶
type PTYSession struct {
Node string
Session *ssh.Session
Stdin io.WriteCloser
// contains filtered or unexported fields
}
PTYSession represents an interactive SSH PTY session on a node.
func GetOrStartPTY ¶
GetOrStartPTY returns an existing alive PTY session or starts a fresh one.
func (*PTYSession) Done ¶
func (p *PTYSession) Done() <-chan struct{}
Done returns a channel that signals when the remote process exits.
func (*PTYSession) Resize ¶
func (p *PTYSession) Resize(cols, rows uint32) error
Resize updates the terminal dimension on the remote node.
func (*PTYSession) Subscribe ¶
func (p *PTYSession) Subscribe() (chan []byte, []byte, func())
Subscribe returns an output channel, the current scrollback buffer, and an unsubscribe function.
func (*PTYSession) WriteInput ¶
func (p *PTYSession) WriteInput(data []byte) error
WriteInput writes user keystrokes to remote stdin and records completed commands to audit.
type ReceiveRequest ¶
ReceiveRequest asks for the speed test's sink: which port, and for how long.
type Request ¶
type Request struct {
Op string `json:"op"`
Body json.RawMessage `json:"body,omitempty"`
}
Request is one operation. Body is the operation's own arguments, left as raw JSON so that a panel and a node running different versions can pass a field neither of them shares an opinion about.
type Response ¶
type Response struct {
OK bool `json:"ok"`
Err string `json:"err,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
}
Response is the answer. Err carries a message meant to be shown to the operator as-is: it is the node's own words about the node's own machine, and rewording it at the panel loses the only description of what actually happened.
func Execute ¶
Execute performs one operation and returns the answer.
Everything not on the list is refused here rather than at the panel. A node that trusted the panel to send only sensible operations would be a node that does whatever anything holding the key asks, and the point of the whole design is that it does not.
type Runner ¶
type Runner interface {
// Call performs one operation on one server. body is encoded into the
// request and out, when not nil, receives the answer.
Call(name, op string, body, out any) error
// IsOnline reports whether the server answered recently.
IsOnline(name string) bool
// Reachable is IsOnline with the reason it is not, for the one screen that
// shows it. A server that is down and a server whose password was changed
// are both "offline" and are not the same problem.
Reachable(name string) (bool, string)
// Forget drops what is held about one server, so the next call starts over.
// Used when its address or login changes, and when it leaves the fleet.
Forget(name string)
}
Runner is how the panel reaches a managed server.
It is the whole surface the rest of the panel ever used: ask one server to do one thing, and ask whether it can be reached at all. Everything else the old enrolment channel carried — the per-node listener, the setup token, the agent holding a session open — existed to make those two calls possible, not because anything needed them.
type SSHRunner ¶
type SSHRunner struct {
// contains filtered or unexported fields
}
SSHRunner drives managed servers over SSH.
func NewSSHRunner ¶
NewSSHRunner returns a runner that reaches servers over their own SSH.
func (*SSHRunner) Close ¶
func (r *SSHRunner) Close()
Close drops every connection the runner is holding.
func (*SSHRunner) ExecRaw ¶
ExecRaw runs an arbitrary shell command on a managed server node over SSH. If command is empty, it runs a basic system info probe.
func (*SSHRunner) Forget ¶
Forget drops what is remembered about one server, so the next look is fresh.
func (*SSHRunner) Install ¶
Install puts Hashem on a server that does not have it, over the same SSH.
This is what makes adding a server one action. The channel it replaces asked the operator to paste a line on the far machine, which meant leaving the panel, finding a terminal for a server they may only have a password for, and coming back — and if anything went wrong there, the panel's side of it was a server that never appeared and no reason why.
The installer is fetched on the far machine from the same place it always was, rather than pushed from here: the archive and its checksum have to come from the same origin for verifying one against the other to prove anything, and a panel in the middle would be a second thing to trust.
It is slow — a download and possibly a build — so the caller gives it room.
func (*SSHRunner) IsOnline ¶
IsOnline reports whether a server answered recently, asking it if the last answer is old.
type SSHTarget ¶
type SSHTarget struct {
Host string
Port int
User string
Password string
// Fingerprint is the host key this server presented when it was added.
//
// Empty means it has not been seen yet, and the first connection records
// what it finds — trust on first use, which is the same bargain as typing
// "yes" at ssh's own prompt. Afterwards it must match: accepting any key
// would mean anything that can answer on this address gets a root shell
// and the panel would never say a word about it.
Fingerprint string
}
SSHTarget is how to reach one server.
type Store ¶
type Store struct {
Nodes []Node `json:"nodes,omitempty"`
}
Store is the whole persisted state.
There is no "enabled" here any more. It existed to say whether the panel should open its listeners; the panel dials out now, so an empty fleet is already the off state and a switch for it was one more thing to be wrong.
type TerminalAuditEntry ¶
type TerminalAuditEntry struct {
Time time.Time `json:"time"`
Node string `json:"node"`
Command string `json:"command"`
}
TerminalAuditEntry represents one command executed or typed in a node terminal.
func LoadTerminalAudit ¶
func LoadTerminalAudit() []TerminalAuditEntry
LoadTerminalAudit returns recorded node terminal commands, newest last.
type TunnelState ¶
type TunnelState struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"` // reverse, direct or l3
Service string `json:"service,omitempty"`
Active bool `json:"active"`
Enabled bool `json:"enabled"`
// Role, TunnelPort and ServerHost are what identify this tunnel as one
// half of a pair.
//
// A tunnel has two ends and nothing in either configuration names the
// other by name — the operator is free to call them anything. What does
// tie them together is the address they meet at: a reverse client dials
// its server's host and port, and that port is exactly the one the server
// binds. So a client over there whose ServerHost is this machine and whose
// TunnelPort is this tunnel's port is this tunnel's other end, and no
// amount of renaming changes that.
//
// They are carried on the list rather than fetched per tunnel because the
// alternative is one SSH round trip per candidate to answer a question
// about all of them.
Role string `json:"role,omitempty"` // server | client
TunnelPort string `json:"tunnelPort,omitempty"` // the port the pair meets on
ServerHost string `json:"serverHost,omitempty"` // client-side only: who it dials
// ServiceDown is set when this server's end of the tunnel is delivering
// connections into nothing — the service it forwards to is not listening.
//
// It crosses the wire because only this end can see it. The panel runs on
// the other machine, where the tunnel looks perfectly healthy and is: the
// control channel is up, the peer is there, the traffic counters move.
// Without this the operator's only route to the fact was to open the far
// server's journal and read it.
ServiceDown string `json:"serviceDown,omitempty"`
}
TunnelState is one tunnel on a node, as the fleet screen shows it.