node

package
v1.3.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 24, 2026 License: AGPL-3.0 Imports: 25 Imported by: 0

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

View Source
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

View Source
var DefaultSSHPool = newPool()

DefaultSSHPool is the shared pool used by runners.

View Source
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.

View Source
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.

View Source
var (
	TermAuditPath = app.ConfigDir + "/node-terminal-audit.json"
)

Functions

func Fingerprint

func Fingerprint(k ssh.PublicKey) string

Fingerprint is the SHA-256 of a host key, in ssh's own display form.

func NoteFingerprint

func NoteFingerprint(name, fp string) error

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 NoteInfo

func NoteInfo(name string, info Info) error

NoteInfo stores what a server last reported about itself.

func RecordTerminalAudit

func RecordTerminalAudit(nodeName, command string)

RecordTerminalAudit appends an audit record for a node command.

func Remove

func Remove(name string) error

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.

func SaveStore

func SaveStore(s Store) error

SaveStore persists the state, root-only and with every password sealed.

Sealing here rather than at each call site is what makes it unconditional: this is the only function that writes the file, so there is no path by which a password reaches the disk in the clear.

func SetCredentials

func SetCredentials(name, host string, sshPort int, user, password string) error

SetCredentials changes how a server is reached. Changing the address clears the host key: a different machine is entitled to a different one.

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.

func LocalInfo

func LocalInfo() Info

LocalInfo describes this machine.

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

type LogsResult struct {
	Name string `json:"name"`
	Text string `json:"text"`
}

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

func Add(name, host string, sshPort int, user, password string) (Node, error)

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.

func Find

func Find(name string) (Node, bool)

Find returns one server by name, without its credential.

func List

func List() []Node

List returns the fleet, oldest first, without credentials.

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

func GetOrStartPTY(ctx context.Context, name string, cols, rows uint32) (*PTYSession, error)

GetOrStartPTY returns an existing alive PTY session or starts a fresh one.

func (*PTYSession) Close

func (p *PTYSession) Close() error

Close closes the PTY session.

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

type ReceiveRequest struct {
	Port    int `json:"port"`
	Seconds int `json:"seconds"`
}

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

func Execute(req Request) Response

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

func NewSSHRunner(onLog func(string)) *SSHRunner

NewSSHRunner returns a runner that reaches servers over their own SSH.

func (*SSHRunner) Call

func (r *SSHRunner) Call(name, op string, body, out any) error

Call performs one operation on one server.

func (*SSHRunner) Close

func (r *SSHRunner) Close()

Close drops every connection the runner is holding.

func (*SSHRunner) ExecRaw

func (r *SSHRunner) ExecRaw(name, command string) (string, error)

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

func (r *SSHRunner) Forget(name string)

Forget drops what is remembered about one server, so the next look is fresh.

func (*SSHRunner) Install

func (r *SSHRunner) Install(name string) (string, error)

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

func (r *SSHRunner) IsOnline(name string) bool

IsOnline reports whether a server answered recently, asking it if the last answer is old.

func (*SSHRunner) Reachable

func (r *SSHRunner) Reachable(name string) (bool, string)

Reachable is IsOnline with the reason, for the one screen that shows it.

func (*SSHRunner) Upgrade

func (r *SSHRunner) Upgrade(name string) (string, error)

Upgrade reinstalls Hashem on a server, which is how a node is brought to the release the panel is on. It is the same script; the installer replaces the binary and restarts what was running.

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.

func LoadStore

func LoadStore() Store

LoadStore reads the persisted state. A missing or unreadable file is an empty fleet, not an error: the panel has to start on a server that has never used this feature.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL