Run a local process as if it were inside your cluster: prefix any command
with plug and it resolves the cluster DNS and reaches the cluster services like
any workload of the stack - no code change, no proxy configuration in your app. One
tiny agent container on the cluster side, one static CLI on yours. Docker, Swarm, Kubernetes
and OpenShift.
Integration
A gateway can run the plug agent inside its own binary instead of beside it.
The agent is a Go package, github.com/softwarity/plug/agent: the SSH server, the
verbs, the names, the parking, all of it compiled into the gateway. The developers keep the
same CLI and the same commands; what changes is who answers four questions the agent cannot
answer alone:
- The server's identity, kept in the gateway's vault rather than in a file a recreated container loses.
- Which keys get in, and under whose name, from the gateway's own users.
- What is served right now, and by whom, so a page can say "checkout, by Alice".
- Which plug runs are open, and the hand to end one.
Meerkat is built this way. This page is what any gateway needs to do the same.
1. Answer the agent: a Host
agent.Host answers the first three: HostKey, Verify,
and the pair Served / Unserved. A Host that also implements
agent.SessionHost hears every plug run open and close.
import (
"golang.org/x/crypto/ssh"
"github.com/softwarity/plug/agent"
)
type host struct{ /* your vault, your key store, your audit */ }
// The server's identity, kept where the gateway keeps its secrets.
func (h *host) HostKey() (ssh.Signer, error) { return h.vault.SSHHostKey() }
// Which keys get in, and under whose name. Called on every connection.
func (h *host) Verify(key ssh.PublicKey) (who string, ok bool) {
dev, ok := h.keys.Lookup(ssh.FingerprintSHA256(key))
return dev.Name, ok
}
// What is served right now, and by whom. Information, never authorisation.
func (h *host) Served(e agent.NameEvent) { h.audit.Served(e) }
func (h *host) Unserved(name string) { h.audit.Unserved(name) }
// Optional (agent.SessionHost): every plug run, as one session.
func (h *host) SessionOpened(e agent.SessionEvent) { h.audit.Opened(e) }
func (h *host) SessionClosed(e agent.SessionEvent) { h.audit.Closed(e) }-
Verifyis your admission: called on every connection, so it must be cheap or cached. An emptywhomeans the key names software, not a person. -
The events are information, never authorisation. They arrive in order on
one queue: a session's
SessionOpenedbefore itsServed, theUnservedof what it still held before itsSessionClosed. A slow or failing Host does not stop a developer, and a panic in it is absorbed and logged. -
Unservedis not optional: without it, a page keeps showing names nobody serves any more.
2. Start it
agent.Start serves until its context is cancelled, and returns errors rather than
exiting. The verbs, on the other hand, exit on error, which is right for a process whose
whole job is one answer and wrong inside a gateway: they run as a subprocess, your binary run
again with a hidden argument.
const verbArg = "__plug-verb"
func main() {
// A verb is a process of its own: the agent runs this binary again with
// the request in SSH_ORIGINAL_COMMAND, and agent.Main answers it.
if len(os.Args) > 1 && os.Args[1] == verbArg {
agent.Main()
return
}
// ... the gateway starts ...
self, _ := os.Executable()
version := readVersion("/opt/plug/VERSION") // what the download account hands out
ctl := agent.NewControl()
go func() {
err := agent.Start(ctx, agent.Config{
Host: &host{ /* ... */ },
Control: ctl,
Addr: ":2222",
Version: version,
VerbCommand: []string{self, verbArg},
SignpostImage: "softwarity/plug:" + version,
NoSelfUpdate: true,
KeyInstruction: "register the key it prints in your profile on this gateway",
})
slog.Error("plug agent stopped", "err", err)
}()
}-
Versionmust be the version the download account answers: the CLI fetches the core for that version and checks its digest. Read it from/opt/plug/VERSION(step 3) and the two cannot drift. -
SignpostImageon Compose and Swarm: the container that carries a served name runs plug's image, not the gateway's, which has no agent binary in it. Kubernetes creates a Service and never uses it. -
NoSelfUpdate: the update verb rewrites the image of the deployment the agent runs in, which is the gateway's. Updating the gateway is the gateway's business. -
KeyInstructionends an install, after "and": where the keyplug keygenjust made goes, in your words. One line, no quote or backslash, 200 characters at most. -
RequireOrchestratorstays false: a missing Docker socket or role is logged and the gateway keeps running. Reaching the cluster (-c) still works; serving a name (-s) says what access is missing. -
NoDownloadAccountcloses the anonymous account developers install from, for a gateway that distributes plug another way.
3. Carry the hosted client
The anonymous account (ssh get@<gateway> install) hands out what
/opt/plug holds: the client binaries, their signatures, the installers and the
version. Copy it from the hosted image, the flavour that carries key
enrolment and leaves updates to the gateway:
# What the download account hands out: the hosted client, its installers, its version.
FROM softwarity/plug:<version>-hosted AS plug
FROM your-gateway-base
COPY /opt/plug /opt/plug4. Give it the cluster
The embedded agent needs what a dedicated one needs to serve names: the Docker socket on
Compose and Swarm, the role of deploy/plug-k8s.yaml on
Kubernetes. With several replicas on Kubernetes, give each one
its pod's address, so a served name points at the replica that holds the session and any
replica can tell a live session from a dead one:
env:
- name: PLUG_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIPWithout it the agent works the address out from the route to the API server, and says so in its log when it cannot.
5. Follow and end sessions
A plug run opens several connections (the tunnel, the served names, the mounts); each presents
the run's id, so the gateway sees one session per run. A
SessionEvent carries Who, Key (the SHA256 fingerprint,
as plug pubkey prints it), From, Version,
Serves (a -s, not only a -c), Docker (a
--dockerrun) and Since; at its end, Ended,
End and Reason. End is ended (the
developer stopped), dropped (gone without a word and not back within 90 seconds),
closed (ended on purpose) or shutdown (the agent stopped). Each
NameEvent names its session.
Config.Control is the gateway's hand on the agent:
// The page that lists live sessions opens on this, then follows the events.
open := ctl.Sessions()
// An administrator ends one run. The developer reads the reason.
ctl.CloseSession(id, "closed by an administrator of this gateway")
// A key withdrawn, or its owner's right: everything it has open ends now.
ctl.CloseKey(fingerprint, "your key was removed from this gateway")An ended session is a statement, not a dropped line. The developer reads one line, the reason as you gave it, their command stops as Ctrl-C would stop it, plug exits with status 77 and does not reconnect, and what the session parked is restored at once. The id is refused for a few minutes, so a client that missed the notice is told again rather than let back in.
With several replicas, a run's connections are spread over them: ask every one. A replica that never saw the session still refuses it from then on. Merge what the replicas report by session id: that is one run.
Closing is not a ban: a developer can start a new run. Keeping someone out is
Verify's answer, which is why CloseKey exists: withdraw the key, then
end what it has open, in the same words.
6. Keep the two on one release
The package compiled in and the /opt/plug copied in come from the
same plug release. Release tags are plug's own (v<version>),
not the Go module's, so the module is pinned by the commit of the release:
v=<version>
commit=$(git ls-remote https://github.com/softwarity/plug.git "refs/tags/v$v^{}" "refs/tags/v$v" | head -1 | cut -f1)
go get "github.com/softwarity/plug/agent@$commit"
and the image by the same version, softwarity/plug:<version>-hosted. Moving
one without the other has the gateway announce a version it does not serve.
Checklist
- A Host: identity, admission, served names; SessionHost to follow runs.
agent.Startin a goroutine, the verbs answered byagent.Mainbehind a hidden argument.Versionread from/opt/plug/VERSION,SignpostImage,NoSelfUpdate,KeyInstruction./opt/plugcopied from the hosted image of the same release.- The Docker socket or the Kubernetes role;
PLUG_POD_IPpast one replica. - A Control, and every replica asked when a session or a key is ended.