portkit

package module
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 41 Imported by: 0

README

Portkit

A Caddy plugin to manage your local dev servers and give them each an HTTPS URL at https://<name>.localhost.

Portkit configures launchd (Mac) or systemd (Linux) to start Caddy whenever there's activity on loopback port 443, and then proxies each name to either a fixed port, or to a process it starts on demand.

portkit register web 3000            # a server you run manually on port 3000
portkit register app -- npm run dev  # run automatically when you visit app.localhost
portkit run api -- npm run dev       # run in this terminal session 
portkit adopt myapp 4100             # a server registering itself
portkit ls

Install

Portkit runs Caddy as your local user. It only needs root privileges during installation to create the systemd units on Linux, and to trust its CA for serving HTTPS on localhost:

curl -fsSL https://github.com/chkn/portkit/releases/latest/download/install.sh | bash

install.sh lists every file it writes and every command it runs with sudo. Running it again upgrades in place, keeping your registered services. Command line options:

  • --dry-run prints the actions without running them.
  • --uninstall reverses everything.
  • --binary ./caddy installs a local build.

Usage

After running the install script, the following commands will be on your PATH:

Command What it does
portkit register NAME PORT Always proxy NAME.localhost to localhost:PORT.
portkit register NAME -- CMD… Start CMD on a free port whenever you visit NAME.localhost, stop it after 30 min idle.
portkit run NAME -- CMD… Run CMD now, in the foreground on this terminal, routing NAME.localhost to it until it exits.
portkit adopt NAME PORT Route NAME.localhost to a running server for as long as the calling process lives.
portkit ls List services with their state and upstream address.
portkit unregister NAME Remove a service and stop its process.
portkit rm NAME Alias for unregister
portkit stop NAME Stop an on-demand process, or clear its crash state.
portkit logs NAME [-f] [-n N] Show an on-demand service's output.
portkit status Caddy's process, the admin socket and the CA certificate.
portkit trust Add the portkit CA to the system trust store (the installer runs it).

Flags for register, run and adopt:

  • --label key=value - Adds an arbitrary label to the service record that can be queried.
  • --sub NAME=PORT (or NAME=/regex/ for register and run) - Serve a subdomain NAME.<name>.localhost.

Flags for register and run:

  • --idle-timeout 30m|never
  • --host-header localhost:{port}
  • --env K=V,
  • --cwd DIR
  • --listen-port N

run also takes --replace to borrow a name that is registered in another mode; the old record comes back when the command exits.

Port assignment

When Portkit runs a server via the register or run command, it uses the following methods to determine which port to proxy to:

  1. --listen-port argument - If you pass a port to the register or run command, it will use that port.
  2. PORT environment variable - Portkit sets this to a free port when it starts the process. If it, or any process in its child process group, listens on this port, Portkit will proxy to this port.
  3. Interpolated command line arguments - In any of the CMD arguments above, you can pass {port}, which will be replaced with a free port the server should listen on. Any interpolation other than {port} will be replaced with a free port for that subdomain (e.g. {foo} will be replaced with a free port and foo.<name>.localhost will proxy to it.
  4. Stdout and stderr - Portkit scans the process's output for a port number. It only picks one that some process in the process group is listening on.
  5. Random port - Portkit gets the list of open ports for the process and its child process group and picks one at random, ignoring some common sidechannel ports (e.g. Node debugger).
Service Discovery

In addition to the portkit ls command, you can also discover registered services through a GET request to https://portkit.localhost/services. Query parameters filter the returned list:

  • label - Returns services with the given label. Can be specified more than once.
  • cwd - Returns services with the given working directory or one of its parents.

Development

go test -race ./...
go build -o caddy ./cmd/caddy      # or: xcaddy build v2.11.7 --with github.com/chkn/portkit=.

To run Caddy by hand without launchd or systemd, set PORTKIT_SOCKETS to have the plugin bind the sockets itself, and point the CLI at the admin socket:

export PORTKIT_HOME=$PWD/dev XDG_DATA_HOME=$PWD/dev/data XDG_CONFIG_HOME=$PWD/dev/config
export PORTKIT_SOCKETS="https=127.0.0.1:8443,https=[::1]:8443,admin=unix:$PWD/dev/admin.sock"
export PORTKIT_ADMIN_SOCKET=$PWD/dev/admin.sock
./caddy run --resume --config bootstrap.json
./caddy portkit register web 3000
curl -k --connect-to web.localhost:8443:127.0.0.1:8443 https://web.localhost:8443/

Socket paths are limited to 104 bytes on macOS, so keep dev/ short.

Releases

Pushing a v* tag runs release.yml, which builds all four targets with CGO_ENABLED=0 on one Linux runner, then publishes the tarballs, install.sh and SHA256SUMS with build attestations. Bump VERSION in install.sh to match the tag first.

License

Portkit is licensed under the Apache License 2.0, the same license as Caddy.

Documentation

Overview

Package portkit is a Caddy plugin that gives every local dev server an HTTPS URL at <name>.localhost.

Caddy runs socket-activated as the user: launchd (macOS) or systemd (Linux) holds the loopback port 443 and the admin socket, and the "portkit" network hands those sockets to Caddy. Services live in the "portkit" app (apps.portkit.services); each one proxies to a fixed port, to a process the supervisor starts on demand, or to a process that `portkit run` or `portkit adopt` registered.

Index

Constants

View Source
const (
	ModePort       = "port"
	ModeOnDemand   = "on_demand"
	ModeForeground = "foreground"
	ModeAdopted    = "adopted"
)

Service modes.

Variables

This section is empty.

Functions

func AdminSocket

func AdminSocket() string

AdminSocket is the path of the admin API socket that launchd or systemd holds.

func AssignPorts

func AssignPorts(svc *Service) (map[string]int, error)

AssignPorts picks a free port for {port} ("") and for each placeholder subdomain.

func CACertPath

func CACertPath() string

CACertPath is the root certificate of the portkit local CA. Caddy runs with XDG_DATA_HOME=$PORTKIT_HOME/data, so its storage is under there.

func ChildEnv

func ChildEnv(base []string, name string, svc *Service, ports map[string]int) []string

ChildEnv builds a child's environment: base, then the service's saved variables, then the variables portkit adds.

func DescribeResult

func DescribeResult(host string, r Result) string

DescribeResult formats the line portkit writes once a port is chosen, e.g. "portkit: foo.localhost → 127.0.0.1:3000 (assigned port)".

func ExpandCommand

func ExpandCommand(argv []string, ports map[string]int) []string

ExpandCommand replaces {port} and {<sub>} with the assigned ports.

func FreePort

func FreePort() (int, error)

FreePort picks a free port by listening on 127.0.0.1:0 and closing it.

func Home

func Home() string

Home is PORTKIT_HOME: the directory holding the binary, config, CA and logs.

func LogDir

func LogDir() string

LogDir holds one log file per on-demand service.

func Placeholders

func Placeholders(argv []string) []string

Placeholders returns the {name} tokens in a command, in order of first use. "{{" and "}}" are literal braces.

func PortFromLine

func PortFromLine(line string) (int, bool)

PortFromLine returns the port a log line names, if any. The line should already have ANSI escapes stripped.

func ProcessStartTime

func ProcessStartTime(pid int) (string, error)

ProcessStartTime identifies a process across PID reuse.

func ProcessUID

func ProcessUID(pid int) (int, error)

ProcessUID returns the user ID a process runs as.

func RunDetection

func RunDetection(d *Detector, pgid int, stop <-chan struct{}, report func(target string, r Result))

RunDetection steps d every 250 ms with the sockets of process group pgid until every target resolves or stop closes. report is called for each result as it resolves.

func ServiceURL

func ServiceURL(name string) string

ServiceURL is the HTTPS URL for a service name or <sub>.<name>.

func StripANSI

func StripANSI(s string) string

StripANSI removes terminal escape sequences.

func ValidLabel

func ValidLabel(s string) bool

ValidLabel reports whether s is a single lowercase DNS label.

func ValidateName

func ValidateName(name string) error

ValidateName checks a service name: one or more DNS labels joined by dots.

Types

type APIHandler

type APIHandler struct {
	// contains filtered or unexported fields
}

APIHandler serves https://portkit.localhost: the discovery endpoint at /services and an index page at /.

func (APIHandler) CaddyModule

func (APIHandler) CaddyModule() caddy.ModuleInfo

func (*APIHandler) Provision

func (h *APIHandler) Provision(ctx caddy.Context) (err error)

func (*APIHandler) ServeHTTP

type AdminAPI

type AdminAPI struct{}

AdminAPI adds runtime endpoints to Caddy's admin API, beside the standard /config/apps/portkit/... endpoints that hold the records:

GET  /portkit/info   Caddy's process, paths and activity
GET  /portkit/state  whether each service is running, and on which port
POST /portkit/stop   {"name": ...}: stop an on-demand process
POST /portkit/adopt  register a running server (see Adopt)

func (AdminAPI) CaddyModule

func (AdminAPI) CaddyModule() caddy.ModuleInfo

func (AdminAPI) Routes

func (AdminAPI) Routes() []caddy.AdminRoute

type AdoptRequest

type AdoptRequest struct {
	Name       string            `json:"name"`
	Port       int               `json:"port"`
	Cwd        string            `json:"cwd,omitempty"`
	Labels     map[string]string `json:"labels,omitempty"`
	Subdomains map[string]int    `json:"subdomains,omitempty"`
	Owner      Owner             `json:"owner"`
}

AdoptRequest is the body of POST /portkit/adopt.

type AdoptResponse

type AdoptResponse struct {
	Name   string   `json:"name"`
	URL    string   `json:"url"`
	Action string   `json:"action"` // "merged", "created" or "took over"
	Record *Service `json:"record"`
}

AdoptResponse says what adopt did.

type App

type App struct {
	// IdleExit is how long Caddy waits with no connections and no running
	// children before it exits: a duration such as "2h", or "never".
	IdleExit string `json:"idle_exit,omitempty"`

	// Services maps each name to its record.
	Services map[string]*Service `json:"services,omitempty"`
	// contains filtered or unexported fields
}

App is apps.portkit: the service registry and Caddy's idle exit.

func (App) CaddyModule

func (App) CaddyModule() caddy.ModuleInfo

func (*App) Provision

func (a *App) Provision(ctx caddy.Context) error

func (*App) Start

func (a *App) Start() error

func (*App) Stop

func (a *App) Stop() error

Stop does nothing: Caddy stops the old app on every config reload, and the supervisor's processes must survive that.

type CrashError

type CrashError struct {
	Name    string
	Message string
	Tail    string
}

CrashError is returned once a service exited quickly too many times.

func (*CrashError) Error

func (e *CrashError) Error() string

type Detector

type Detector struct {
	// contains filtered or unexported fields
}

Detector resolves targets from snapshots of listening sockets and the lines a process prints. Step is called every 250 ms until every target is resolved; it doesn't touch the OS itself, so it can be tested.

func NewDetector

func NewDetector(targets []Target, accepts func(int) (string, bool)) *Detector

NewDetector makes a detector. accepts reports whether a port outside the process group takes connections; nil uses probePort.

func (*Detector) AddLine

func (d *Detector) AddLine(raw string)

AddLine records an output line.

func (*Detector) Done

func (d *Detector) Done() bool

Done reports whether every target is resolved.

func (*Detector) Ready

func (d *Detector) Ready(target string) <-chan struct{}

Ready is closed once the target's port is known.

func (*Detector) Reset

func (d *Detector) Reset(target string)

Reset forgets a target's result so detection runs again, for example after dials to it start failing.

func (*Detector) Result

func (d *Detector) Result(target string) (Result, bool)

Result returns the detected port for a target.

func (*Detector) Step

func (d *Detector) Step(socks []Socket) []Resolved

Step runs one round of detection against the current sockets and returns the targets newly resolved in this round.

type DiscoveredService

type DiscoveredService struct {
	Name       string            `json:"name"`
	URL        string            `json:"url"`
	Mode       string            `json:"mode"`
	Cwd        string            `json:"cwd,omitempty"`
	Labels     map[string]string `json:"labels,omitempty"`
	Subdomains map[string]string `json:"subdomains,omitempty"`
	Running    bool              `json:"running"`
}

DiscoveredService is one entry of GET /services. Commands and environments are left out.

type HelpHandler

type HelpHandler struct {
	// contains filtered or unexported fields
}

HelpHandler serves the page for names that aren't registered.

func (HelpHandler) CaddyModule

func (HelpHandler) CaddyModule() caddy.ModuleInfo

func (*HelpHandler) Provision

func (h *HelpHandler) Provision(ctx caddy.Context) (err error)

func (*HelpHandler) ServeHTTP

type Info

type Info struct {
	PID         int       `json:"pid"`
	Version     string    `json:"version"`
	Started     time.Time `json:"started"`
	Home        string    `json:"home"`
	CACert      string    `json:"ca_cert"`
	Connections int       `json:"connections"`
	Children    int       `json:"children"`
	IdleExit    string    `json:"idle_exit"`
}

Info is the response of GET /portkit/info.

type Matcher

type Matcher struct {
	// contains filtered or unexported fields
}

Matcher matches requests whose host is a registered service with a live process or port, and records the service for the handlers after it.

func (Matcher) CaddyModule

func (Matcher) CaddyModule() caddy.ModuleInfo

func (*Matcher) Match

func (m *Matcher) Match(r *http.Request) bool

func (*Matcher) MatchWithError

func (m *Matcher) MatchWithError(r *http.Request) (bool, error)

func (*Matcher) Provision

func (m *Matcher) Provision(ctx caddy.Context) (err error)

type Owner

type Owner struct {
	PID     int    `json:"pid"`
	Started string `json:"started"`
}

Owner identifies a process by ID and start time, so a reused ID doesn't count as the same process.

func OwnerOf

func OwnerOf(pid int) (*Owner, error)

OwnerOf describes a running process as an Owner.

type Permission

type Permission struct {
	// contains filtered or unexported fields
}

Permission allows on-demand certificates for any name under .localhost with up to four labels, registered or not, so the help page loads for unregistered names. At most 10 new unregistered names a minute get one, so a runaway script can't fill the disk.

func (Permission) CaddyModule

func (Permission) CaddyModule() caddy.ModuleInfo

func (*Permission) CertificateAllowed

func (p *Permission) CertificateAllowed(_ context.Context, host string) error

func (*Permission) Provision

func (p *Permission) Provision(ctx caddy.Context) (err error)

type ProcState

type ProcState struct {
	State      string            `json:"state"` // "running", "starting", "stopped" or "crashed"
	PID        int               `json:"pid,omitempty"`
	Addr       string            `json:"addr,omitempty"`
	Subdomains map[string]string `json:"subdomains,omitempty"`
	Started    *time.Time        `json:"started,omitempty"`
	Error      string            `json:"error,omitempty"`
}

ProcState is the runtime state of an on-demand service.

type Resolved

type Resolved struct {
	Target string
	Result
}

Resolved is a target resolved by Step.

type Result

type Result struct {
	Addr   string // host:port to dial
	Port   int
	Reason string
}

Result is a detected port and why it was chosen.

type Service

type Service struct {
	Mode    string            `json:"mode"`
	Port    int               `json:"port,omitempty"`
	Command []string          `json:"command,omitempty"`
	Cwd     string            `json:"cwd,omitempty"`
	Env     map[string]string `json:"env,omitempty"`
	Labels  map[string]string `json:"labels,omitempty"`

	// Subdomains are served at <sub>.<name>.localhost.
	Subdomains map[string]*Subdomain `json:"subdomains,omitempty"`

	// IdleTimeout is a duration such as "30m", or "never".
	IdleTimeout string `json:"idle_timeout,omitempty"`

	// HostHeader replaces the Host header sent upstream, e.g. "localhost:{port}".
	HostHeader string `json:"host_header,omitempty"`

	// ListenPort skips port detection for a process whose port is known.
	ListenPort int `json:"listen_port,omitempty"`

	// Owner is the process a "foreground" or "adopted" record lives with.
	Owner *Owner `json:"owner,omitempty"`

	// Replaces is the record an adopter took the name from; it applies
	// again when the owner exits.
	Replaces *Service `json:"replaces,omitempty"`
	// contains filtered or unexported fields
}

Service is one record in apps.portkit.services. The key is its name.

type ServiceState

type ServiceState struct {
	ProcState
	Mode string `json:"mode"`
}

ServiceState is the runtime state of one service in GET /portkit/state.

type Socket

type Socket struct {
	IP   string
	Port int
}

Socket is one TCP socket in LISTEN state.

func ListeningSockets

func ListeningSockets(pgid int) ([]Socket, error)

ListeningSockets returns the TCP sockets in LISTEN state owned by process group pgid, using lsof.

type StartHandler

type StartHandler struct {
	// contains filtered or unexported fields
}

StartHandler runs before reverse_proxy. For an on-demand service it starts the process and waits for its port, and counts the request as activity for the idle timeout. For the other modes it checks that something listens on the port. Either way it shows a page instead of proxying when the service can't be reached.

func (StartHandler) CaddyModule

func (StartHandler) CaddyModule() caddy.ModuleInfo

func (*StartHandler) Provision

func (h *StartHandler) Provision(ctx caddy.Context) (err error)

func (*StartHandler) ServeHTTP

func (h *StartHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error

type Subdomain

type Subdomain struct {
	Assign bool   `json:"assign,omitempty"`
	Port   int    `json:"port,omitempty"`
	Match  string `json:"match,omitempty"`
	// contains filtered or unexported fields
}

Subdomain declares where a subdomain's port comes from: a placeholder in the command (Assign), a fixed Port, or the first output line that Matches.

type Target

type Target struct {
	Name     string
	Assigned int            // assigned through PORT, {port} or {<sub>}
	Fixed    int            // known in advance: --listen-port or --sub NAME=PORT
	Match    *regexp.Regexp // --sub NAME=/regex/
}

Target is one port to find: the main port ("") or a subdomain's.

func Targets

func Targets(svc *Service, ports map[string]int) []Target

Targets lists the ports detection has to find for svc.

type Upstreams

type Upstreams struct {
	// contains filtered or unexported fields
}

Upstreams is the reverse_proxy upstream source: it finds the service for the request's host, starts it if needed, waits until its port is known and returns the exact address the process listens on.

func (Upstreams) CaddyModule

func (Upstreams) CaddyModule() caddy.ModuleInfo

func (*Upstreams) GetUpstreams

func (u *Upstreams) GetUpstreams(r *http.Request) ([]*reverseproxy.Upstream, error)

func (*Upstreams) Provision

func (u *Upstreams) Provision(ctx caddy.Context) (err error)

func (*Upstreams) ResetCache

func (u *Upstreams) ResetCache(r *http.Request) error

ResetCache runs when a dial failed and reverse_proxy is about to retry: detection runs again.

Directories

Path Synopsis
cmd
caddy command
Command caddy is Caddy with the portkit plugin, for local builds.
Command caddy is Caddy with the portkit plugin, for local builds.

Jump to

Keyboard shortcuts

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