clink

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 26, 2026 License: MIT Imports: 21 Imported by: 0

README

Connect CLI invocations to a long-running daemon instance of the same binary.

One binary, two modes: the daemon runs endlessly (serve), and subsequent invocations connect to it to execute CLI commands or open a TUI.

Core API

type Config struct {
    Host     string // defaults to "127.0.0.1" on both server and client
    Port     int
    Password string // optional; empty disables auth (loopback only; not safe on multi-user hosts)

    HostKeyPEM    []byte // server: optional persisted SSH host private key (PEM). Empty = ephemeral per Listen.
    HostPublicKey []byte // client: pinned host public key (authorized_keys format). Required for non-loopback Host; empty = no verification (loopback only, MITM risk on remote).

    ShutdownGrace time.Duration // server: how long Listen waits for in-flight handlers after ctx cancel before force-closing. Zero = 5s.
}

Note: Host is checked as a literal IP. Hostnames (e.g. "localhost") are treated as non-loopback — Listen rejects them when Password is empty, and Connect rejects them when HostPublicKey is empty. Use a literal loopback IP (e.g. 127.0.0.1) or set Password / HostPublicKey explicitly.


type Session interface {
    io.Reader
    io.Writer
    Stderr() io.Writer

    // File forwarding: server reads/writes 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. Transfers are confirmed
    // end-to-end: a truncated read or a failed write surfaces as an error
    // (from Close, for the streaming forms) rather than silent partial data.
    ReadFile(path string) ([]byte, error)
    OpenFile(path string) (io.ReadCloser, error)
    WriteFile(path string, data []byte) error
    CreateFile(path string) (io.WriteCloser, error)
}

type Handler func(ctx context.Context, s Session, args []string) error

// Handler is the single dispatch point. args is empty for interactive
// (no-args) clients — return *Interactive there to launch the main TUI.
// Return ErrNotHandled if the command is unknown; the session exits 127.
// Return *ExitError to set a custom remote exit code:
//   return &clink.ExitError{Code: 2, Err: err}
// Return *Interactive to launch a Bubble Tea TUI for this command:
//   return &clink.Interactive{Model: dashboard.New()}
//   (subcommand TUIs require the client to call Connect with clink.WithPTY)
// ctx is cancelled when the client disconnects.
// Handler runs on one goroutine per session, concurrently with other sessions —
// it and everything it closes over must be goroutine-safe. See "Concurrency".

// Daemon side: listen for incoming commands and TUI sessions.
clink.Listen(ctx, conf, handler)

// Client side. Connect forwards args to the daemon.
// AutoPTY allocates a PTY iff os.Stdin is a terminal (mirrors ssh's default),
// so the same call handles TUI subcommands and pipe-friendly plain commands.
// WithLocalCommand names a subcommand that must run locally instead of being
// forwarded (typically the one that starts the daemon). Connect refuses to
// invoke a local command when a daemon is already reachable, preventing a
// double-run.
// WithLocalFallback names a command that runs locally ONLY when no daemon is
// reachable; if the daemon is up, Connect forwards as usual. Use name "" to
// match the no-args invocation — handy for an entry that opens the dashboard
// when the daemon runs and starts the daemon otherwise.
clink.Connect(ctx, conf, args,
    clink.AutoPTY(),
    clink.WithLocalCommand("run", runLocally),
    clink.WithLocalFallback("", runLocally),
)

Usage

clink is framework-agnostic: the client only forwards, so it doesn't need any CLI framework. Only the run command (which starts the daemon) uses one on its way to clink.Listen. Below uses urfave/cli v3; cobra works identically.

package main

import (
    "context"
    "os"
    "os/signal"
    "syscall"

    "github.com/go-devkit/clink"
    "github.com/urfave/cli/v3"
)

var conf = clink.Config{Port: 2222, Password: "secret"}

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    err := clink.Connect(ctx, conf, os.Args[1:],
        clink.AutoPTY(),
        clink.WithLocalCommand("run", runLocally),
        // Optional: `myapp` (no subcommand) starts the daemon if none is up,
        // else forwards and opens the main TUI.
        clink.WithLocalFallback("", runLocally),
    )
    if err != nil {
        os.Exit(1)
    }
}

// runLocally is invoked instead of Connect when args[0] == "run".
// This is where wire/DI builds the full application and hands the CLI
// tree to clink.Listen.
func runLocally(ctx context.Context, _ []string) error {
    // e.g. wire.BuildApp() — full DI happens ONLY here, on the server env.
    app, cleanup, err := buildApp(ctx)
    if err != nil {
        return err
    }
    defer cleanup()

    handler := func(hctx context.Context, s clink.Session, args []string) error {
        // Build a fresh command tree per session: handlers run concurrently and
        // a shared *cli.Command carries per-run state (I/O writers, parsed
        // flags) that two sessions would race on.
        cmd := app.NewRoot() // *cli.Command with wire-injected subcommands
        // urfave/cli v3 inherits Reader/Writer/ErrWriter from parent, so only
        // the root needs wiring.
        cmd.Reader, cmd.Writer, cmd.ErrWriter = s, s, s.Stderr()
        return cmd.Run(clink.WithSession(hctx, s), append([]string{cmd.Name}, args...))
    }
    return clink.Listen(ctx, conf, handler)
}
  • myapp run — starts the daemon; refuses if one is already up.
  • myapp anything else … — forwards to the daemon. PTY allocated when stdin is a tty (TUI subcommands work); no PTY when piped (myapp report | jq works).
  • myapp (no subcommand) — opens the daemon's shell-mode session; Handler receives empty args and can return *Interactive for the main TUI.

The client binary never touches wire, DB, or any server-only service. Only runLocally does. Same binary on both sides.

Concurrency

Handler runs on its own goroutine per session, and clink serializes nothing: several clients — or several sessions from one client — can be inside Handler simultaneously. Handler and everything it closes over (CLI command tree, wire-built services, caches, loggers) must be safe for concurrent use.

CLI frameworks are usually not. A single *cli.Command (urfave) or *cobra.Command holds per-run state — the I/O writers you assign and the parsed flag values — so reusing one instance across sessions races and can leak one client's output into another's. Build or clone the command tree inside Handler, as the example above does.

A single Session is per-connection and not safe for concurrent use by multiple goroutines within one Handler call.

Security model

clink assumes daemon and clients share one trust domain, typically a loopback-bound daemon serving processes of the same user:

  • Empty Password means no authentication. Listen then accepts any public key, and Connect authenticates with a throwaway in-memory key. Any local user or process able to reach the port gets a session — and thus arbitrary command execution plus file read/write on connecting clients' behalf. Listen refuses to start in this mode on a non-loopback host, but on a multi-user machine loopback is not a boundary: set Password.
  • No rate limiting, no connection or session cap. Password auth compares a SHA-256 digest in constant time, but nothing throttles guesses or bounds the number of open connections. Don't expose the port; front it with a real gateway if you must.
  • Host key pinning is opt-in. Without HostKeyPEM the daemon's key is ephemeral per start, so clients cannot pin it across restarts. For any non-loopback Host, Connect requires HostPublicKey.
  • File forwarding is allowlisted, not sandboxed. The client only serves paths that appeared verbatim in the args it passed to Connect, so the daemon cannot read arbitrary client files — but it can read or overwrite anything the user named on the command line.

Versioning

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 instead of reporting a mismatch. Restart the daemon after upgrading the binary.

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

Constants

This section is empty.

Variables

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

func Connect(ctx context.Context, conf Config, args []string, opts ...ConnectOption) error

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

func ExitCode(err error) (code int, ok bool)

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

func Listen(ctx context.Context, conf Config, handler Handler) error

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.

func WithSession

func WithSession(ctx context.Context, s Session) context.Context

WithSession attaches s to ctx so handlers reachable via SessionFrom can use it. The daemon-side dispatcher (whatever runs the CLI framework's command tree) is responsible for calling this before invoking actions.

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

func WithLocalCommand(name string, fn func(context.Context, []string) error) ConnectOption

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

func WithLocalFallback(name string, fn func(context.Context, []string) error) ConnectOption

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

type ExitError struct {
	Code int
	Err  error
}

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.

func (*ExitError) Error

func (e *ExitError) Error() string

func (*ExitError) Unwrap

func (e *ExitError) Unwrap() error

type Handler

type Handler func(ctx context.Context, s Session, args []string) error

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

func SessionFrom(ctx context.Context) Session

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.

Jump to

Keyboard shortcuts

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