Documentation
¶
Overview ¶
Package clink connects CLI invocations to a long-running daemon instance of the same binary. One binary runs in two modes: a daemon that listens for commands, and short-lived CLI invocations that forward their arguments to it. SSH is used as the transport but is fully hidden from the consumer — nothing outside this package imports an SSH library.
The daemon calls Listen with a Handler; each client calls Connect with the arguments to forward. A Handler can run an ordinary command, set a remote exit code via ExitError, or launch a Bubble Tea TUI via Interactive. When Connect is given no arguments it opens an interactive session against the daemon's main TUI.
clink also forwards files from the client's filesystem on the daemon's request (see Session), local-command dispatch that bypasses the daemon (see WithLocalCommand and WithLocalFallback), client signals, and terminal resizes.
clink assumes the daemon and its clients share one trust domain — typically a loopback-bound daemon serving processes of the same user. It does no rate limiting and caps neither connections nor sessions; an empty Config Password disables authentication. Do not expose the port. See the Config, Handler, and Session documentation and the README for the full security model.
Index ¶
- Variables
- func Connect(ctx context.Context, conf Config, args []string, opts ...ConnectOption) error
- func ExitCode(err error) (code int, ok bool)
- func Listen(ctx context.Context, conf Config, handler Handler) error
- func WithSession(ctx context.Context, s Session) context.Context
- type Config
- type ConnectOption
- type ExitError
- type Handler
- type Interactive
- type Session
Constants ¶
This section is empty.
Variables ¶
var ErrNotHandled = errors.New("command not handled")
ErrNotHandled is returned by a Handler to indicate the command was not recognized. The session then exits with code 127 (the shell "command not found" convention), so a client can distinguish an unhandled command from one that ran and succeeded.
Functions ¶
func Connect ¶
Connect sends args to the running daemon. If args is empty, it opens an interactive TUI session. If args[0] matches a WithLocalCommand name, Connect first checks that no daemon is already reachable on the configured host/port; if one is, Connect returns an error to prevent a double-run. Otherwise it invokes the local handler instead of dialing. ctx is used for the local-command handler and for cancelling the connection setup (dial + handshake); it does not itself cancel an in-flight remote command. To cancel a running non-PTY command, deliver SIGINT/SIGTERM to the client process — Connect forwards it to the daemon, which cancels the handler's context. PTY sessions deliver Ctrl-C in-band and forward terminal resizes (SIGWINCH) to the server-side TUI.
func ExitCode ¶
ExitCode reports the remote command's exit status from an error returned by Connect. ok is true when err represents a completed remote command that exited non-zero (the daemon already streamed its output to this process, so callers should exit with the returned code without printing err). For a nil error the code is 0; for connection/transport errors ok is false and the caller should report err itself.
func Listen ¶
Listen starts the daemon and handles incoming CLI commands and TUI sessions. Handler receives empty args for interactive (no-args) clients and can return *Interactive to launch a TUI for them — same mechanism as subcommand TUIs.
There is no version or protocol negotiation between Connect and Listen: the wire contract (argv forwarding, the "file-request" channel payload, exit-code signalling) is assumed identical on both ends because both ends are the same binary. A client built against a different clink version than the running daemon may fail in unhelpful ways rather than reporting a version mismatch. After upgrading the binary, restart the daemon.
When ctx is cancelled, Listen stops accepting connections and cancels every in-flight handler's context, then waits up to a short grace period for them to return before force-closing. A handler that ignores its context is cut off at the deadline rather than blocking shutdown.
Types ¶
type Config ¶
type Config struct {
Host string
Port int
Password string
// HostKeyPEM (server) is an optional PEM-encoded private key used as the
// daemon's SSH host key. When empty, Listen generates an ephemeral key on
// each start; clients then cannot pin the host key across restarts.
HostKeyPEM []byte
// HostPublicKey (client) is an SSH public key in authorized_keys format
// that Connect uses to verify the daemon's host key, preventing MITM.
// Required when Host is not a literal loopback IP; empty disables
// verification (loopback only).
HostPublicKey []byte
// ShutdownGrace (server) bounds how long Listen waits for in-flight handlers
// to finish after its context is cancelled before force-closing the listener.
// Cancelling the context already cancels each handler's ctx, so this only
// bounds handlers that ignore cancellation. Zero uses a 5s default.
ShutdownGrace time.Duration
}
Config holds the connection settings for Listen and Connect.
Host defaults to "127.0.0.1" on both server (Listen) and client (Connect). Setting Host to "" keeps Listen bound to loopback only, which is the safe default for a local daemon.
Password is optional. When empty, Listen accepts any public key and Connect authenticates with an ephemeral in-memory key — effectively no auth. Listen returns an error if Password is empty and Host is not a literal loopback IP (e.g. 127.0.0.1, ::1); hostnames are rejected to avoid DNS-dependent safety checks. On shared hosts any local user or process that can reach the loopback port can connect; set Password when running on multi-user machines.
clink does no rate limiting and caps neither concurrent connections nor concurrent sessions. Password auth compares a SHA-256 digest in constant time, but an attacker that can reach the port may guess passwords as fast as it can open connections. The intended deployment is a loopback-bound daemon serving the same trust domain as its clients; put a real gateway in front of it before exposing the port.
type ConnectOption ¶
type ConnectOption func(*connectOpts)
ConnectOption configures Connect. See WithPTY, AutoPTY, WithLocalCommand.
func AutoPTY ¶
func AutoPTY() ConnectOption
AutoPTY makes Connect allocate a PTY when os.Stdin is a terminal, and skip PTY allocation when stdin is piped/redirected. This mirrors what ssh does by default and lets a single Connect call handle both TUI subcommands (tty stdin) and pipe-friendly non-TUI subcommands.
func WithLocalCommand ¶
WithLocalCommand registers a command that must not be forwarded to the daemon. If args[0] matches name (or name == "" and args is empty), Connect calls fn instead of dialing. Connect returns an error if a daemon is already reachable, to prevent a double-run — use WithLocalFallback for forward-if-up / run-if-down semantics.
func WithLocalFallback ¶
WithLocalFallback registers a command that runs locally only when no daemon is reachable. If args[0] matches name (or name == "" and args is empty) and the daemon is up, Connect forwards as usual; if the daemon is down, fn runs instead. Typical use: no-args entry that opens the TUI when the daemon is running and starts the daemon otherwise.
func WithPTY ¶
func WithPTY() ConnectOption
WithPTY makes Connect allocate a PTY for the command session, enabling the server-side Handler to launch an Interactive (Bubble Tea) TUI for that command. Without it, Interactive returns from Handler will fail with an exit-1 message on the server.
type ExitError ¶
ExitError lets a Handler set a custom remote exit code. Wrap or return directly; if Err is non-nil it is written to the session's stderr.
type Handler ¶
Handler processes a CLI command received from a connected client. args contains the command arguments as sent by the client; args is empty for interactive (no-args) clients. Return ErrNotHandled if the command is not recognized — the session exits 127. Return *ExitError to set a custom remote exit code. Return *Interactive to launch a Bubble Tea TUI for this command.
ctx is the command's lifetime: it is cancelled when the client disconnects and when the client forwards SIGINT/SIGTERM (Ctrl-C on a non-PTY command). Work that must outlive the request — e.g. a background task the client only kicks off — must not use ctx; spawn it with the daemon's own root context instead.
Handler runs on its own goroutine per session and clink serializes nothing: several clients (or several sessions from one client) can be inside Handler at the same time. Handler and everything it closes over — the CLI command tree, wire-built services, caches — must be safe for concurrent use. Note that CLI frameworks generally are not: mutating a shared *cli.Command (its Reader/Writer/ErrWriter, or urfave's parsed flag state) from two sessions races. Build or clone the command tree inside Handler rather than reusing one instance across sessions.
Session itself is per-connection and not safe for concurrent use by multiple goroutines within one Handler call.
type Interactive ¶
type Interactive struct {
Model tea.Model
Opts []tea.ProgramOption
}
Interactive is returned by a Handler to launch a Bubble Tea TUI. Empty-args sessions: the client already allocates a PTY before opening the shell. Non-empty subcommands: the client must opt in via clink.WithPTY(); without it the session exits 1 with a stderr message.
func (*Interactive) Error ¶
func (*Interactive) Error() string
type Session ¶
type Session interface {
io.Reader
io.Writer
Stderr() io.Writer
ReadFile(path string) ([]byte, error)
OpenFile(path string) (io.ReadCloser, error)
WriteFile(path string, data []byte) error
CreateFile(path string) (io.WriteCloser, error)
}
Session provides I/O for command handlers.
ReadFile/OpenFile/WriteFile/CreateFile request files on the client's local filesystem. The client enforces an exact-string allowlist derived from the args it passed to Connect: only paths matching one of those argv strings (or the RHS of any "-"-prefixed "key=value" arg, e.g. "--file=/tmp/x" or "-f=/tmp/x") are served. Anything else is rejected with "path not in allowlist".
Each transfer is confirmed end-to-end: the client reports whether it read or wrote every byte. ReadFile and WriteFile return that status directly. For the streaming forms, the status is reported by Close — always check the error from the io.ReadCloser / io.WriteCloser's Close, as a truncated transfer surfaces there rather than as a short read or a silent success.
func SessionFrom ¶
SessionFrom returns the clink.Session attached to ctx by the daemon, or nil if ctx was not produced by a clink handler. Use inside CLI actions to reach Session.ReadFile/WriteFile and friends.