bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
package main
|
||||
|
||||
// pair_unix.go: pairing with a Host on THIS machine, which has no platform.
|
||||
//
|
||||
// `privatae-bastion pair --redeem-url unix:///run/privatae-host/enroll.sock <token>`
|
||||
// redeems the token over that socket instead of the platform's ingress. The
|
||||
// request body is the one a platform redeem sends. The answer names no relay
|
||||
// and no tunnel addresses. It names a lane socket and a runs directory in the
|
||||
// same shared directory, and the Host's own CA, which this pairing pins the
|
||||
// way a platform pairing pins the platform's CA: it is the only root the lanes
|
||||
// trust, and a later redeem that brings a different CA is refused.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unixRedeemPath is the Host's enrolment endpoint on its enroll socket.
|
||||
const unixRedeemPath = "/bastion/enroll/redeem"
|
||||
|
||||
// parseUnixRedeemURL accepts unix:///<absolute socket path> and nothing
|
||||
// else, and returns the path. A host, a port, user info, a query, a relative
|
||||
// or unclean path are all refused: the URL names a file on this machine,
|
||||
// never a network address.
|
||||
func parseUnixRedeemURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("--redeem-url %q does not parse: %v", raw, err)
|
||||
}
|
||||
if u.Scheme != "unix" {
|
||||
return "", fmt.Errorf("--redeem-url must be unix:///<socket path> for a Host on this machine, got %q "+
|
||||
"(a platform pairing needs no --redeem-url)", raw)
|
||||
}
|
||||
if u.Host != "" || u.User != nil || u.Opaque != "" {
|
||||
return "", fmt.Errorf("--redeem-url %q names a network address; a unix:// redeem URL names a socket path "+
|
||||
"on this machine only, like unix:///run/privatae-host/enroll.sock", raw)
|
||||
}
|
||||
if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
|
||||
return "", fmt.Errorf("--redeem-url %q carries a query or fragment; it names a socket path only", raw)
|
||||
}
|
||||
if !cleanAbsSocketPath(u.Path) {
|
||||
return "", fmt.Errorf("--redeem-url %q must name an absolute, clean socket path", raw)
|
||||
}
|
||||
return u.Path, nil
|
||||
}
|
||||
|
||||
// cleanAbsSocketPath: absolute, already clean, not the root, no NUL.
|
||||
func cleanAbsSocketPath(p string) bool {
|
||||
return strings.HasPrefix(p, "/") && p != "/" && path.Clean(p) == p && !strings.ContainsRune(p, 0)
|
||||
}
|
||||
|
||||
// insideDir says p is strictly below dir (both clean and absolute).
|
||||
func insideDir(p, dir string) bool {
|
||||
return cleanAbsSocketPath(p) && strings.HasPrefix(p, strings.TrimSuffix(dir, "/")+"/")
|
||||
}
|
||||
|
||||
// unixRedeemResp is the Host's answer. The design spells the certificate
|
||||
// fields operator_cert and ca_cert and names the Host by host_handle; the
|
||||
// platform's answer spells them operator_crt, ca_crt and host_name. Either
|
||||
// spelling is read, so the two sides cannot disagree on a key name.
|
||||
type unixRedeemResp struct {
|
||||
OperatorCert string `json:"operator_cert"`
|
||||
OperatorCertAlt string `json:"operator_crt"`
|
||||
CACert string `json:"ca_cert"`
|
||||
CACertAlt string `json:"ca_crt"`
|
||||
Bearer string `json:"bearer"`
|
||||
Transport string `json:"transport"`
|
||||
LaneSocket string `json:"lane_socket"`
|
||||
RunsDir string `json:"runs_dir"`
|
||||
HostHandle string `json:"host_handle"`
|
||||
HostName string `json:"host_name"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (r unixRedeemResp) operatorCert() string {
|
||||
return firstNonEmpty(r.OperatorCert, r.OperatorCertAlt)
|
||||
}
|
||||
func (r unixRedeemResp) caCert() string { return firstNonEmpty(r.CACert, r.CACertAlt) }
|
||||
|
||||
// hostName is what the pairing is filed under and the name the Host's lane
|
||||
// certificate must carry: host_name when the Host sends one, else host_handle.
|
||||
func (r unixRedeemResp) hostName() string { return firstNonEmpty(r.HostName, r.HostHandle) }
|
||||
|
||||
// unixRedeemDial is how the redeem reaches the enroll socket. A seam only so
|
||||
// a test can count that nothing was dialled; nothing outside tests sets it.
|
||||
var unixRedeemDial = func(ctx context.Context, sock string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, "unix", sock)
|
||||
}
|
||||
|
||||
// redeemOverUnix POSTs the redeem body to the Host's enroll socket. Plain
|
||||
// HTTP: the socket is a file in a root-owned directory the Host shares with
|
||||
// this bastion, and what is pinned comes back in the answer.
|
||||
func redeemOverUnix(sock string, body []byte) (unixRedeemResp, error) {
|
||||
var rr unixRedeemResp
|
||||
fi, err := os.Lstat(sock)
|
||||
if err != nil {
|
||||
return rr, fmt.Errorf("redeem: the Host's enroll socket %s: %w", sock, err)
|
||||
}
|
||||
if fi.Mode()&os.ModeSocket == 0 {
|
||||
return rr, fmt.Errorf("redeem: %s is not a socket (a link or a file is never followed)", sock)
|
||||
}
|
||||
httpc := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{
|
||||
// The URL's host is ignored: every request goes to this one socket.
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return unixRedeemDial(ctx, sock) },
|
||||
}}
|
||||
resp, err := httpc.Post("http://localhost"+unixRedeemPath, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return rr, fmt.Errorf("redeem: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
_ = json.Unmarshal(rb, &rr)
|
||||
if resp.StatusCode != 200 {
|
||||
if rr.Error != "" {
|
||||
return rr, fmt.Errorf("redeem rejected: %s", rr.Error)
|
||||
}
|
||||
return rr, fmt.Errorf("redeem rejected: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
}
|
||||
return rr, nil
|
||||
}
|
||||
|
||||
// pemCerts is every CERTIFICATE block in s, parsed; nil when there is none or
|
||||
// one does not parse.
|
||||
func pemCerts(s string) []*x509.Certificate {
|
||||
var out []*x509.Certificate
|
||||
rest := []byte(s)
|
||||
for {
|
||||
var b *pem.Block
|
||||
b, rest = pem.Decode(rest)
|
||||
if b == nil {
|
||||
break
|
||||
}
|
||||
if b.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
c, err := x509.ParseCertificate(b.Bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameCAs: the two PEM bundles hold exactly the same certificates.
|
||||
func sameCAs(a, b string) bool {
|
||||
ca, cb := pemCerts(a), pemCerts(b)
|
||||
if len(ca) == 0 || len(ca) != len(cb) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range ca {
|
||||
seen[string(c.Raw)] = true
|
||||
}
|
||||
for _, c := range cb {
|
||||
if !seen[string(c.Raw)] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// unixAnswerRefusal checks a Host's answer before anything is saved: it is a
|
||||
// unix answer, it carries the certificates and the bearer, the operator
|
||||
// certificate is ours and chains to the CA it came with, the host name can be
|
||||
// a file name, and the lane socket and runs directory are inside the shared
|
||||
// directory the owner named on the command line (the enroll socket's).
|
||||
// existing is the pairing a --replace rotates (nil otherwise); others are the
|
||||
// box's other pairings.
|
||||
func unixAnswerRefusal(rr unixRedeemResp, enrollSock, opKey string, existing *Config, others []*Config) error {
|
||||
if rr.Transport != transportUnix {
|
||||
return fmt.Errorf("the Host answered transport %q over its enroll socket; only \"unix\" is a pairing with a Host on this machine", rr.Transport)
|
||||
}
|
||||
for k, v := range map[string]string{"operator_cert": rr.operatorCert(), "ca_cert": rr.caCert(), "bearer": rr.Bearer,
|
||||
"lane_socket": rr.LaneSocket, "runs_dir": rr.RunsDir, "host_handle": rr.hostName()} {
|
||||
if v == "" {
|
||||
return fmt.Errorf("redeem response missing %s", k)
|
||||
}
|
||||
}
|
||||
if !validHostName(rr.hostName()) {
|
||||
return fmt.Errorf("redeem response has an unsafe host name %q — refusing", rr.hostName())
|
||||
}
|
||||
shared := path.Dir(enrollSock)
|
||||
if !insideDir(rr.LaneSocket, shared) {
|
||||
return fmt.Errorf("the Host's lane_socket %q is not inside %s, the directory its enroll socket is in — refusing", rr.LaneSocket, shared)
|
||||
}
|
||||
if !insideDir(rr.RunsDir, shared) {
|
||||
return fmt.Errorf("the Host's runs_dir %q is not inside %s, the directory its enroll socket is in — refusing", rr.RunsDir, shared)
|
||||
}
|
||||
cas := pemCerts(rr.caCert())
|
||||
if len(cas) == 0 {
|
||||
return errors.New("the Host's ca_cert holds no certificate")
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(rr.operatorCert()), []byte(opKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not for this bastion's key: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert does not parse: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
for _, c := range cas {
|
||||
pool.AddCert(c)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}}); err != nil {
|
||||
return fmt.Errorf("the Host's operator_cert is not signed by the CA it sent: %v", err)
|
||||
}
|
||||
// THE PIN. A --replace keeps the CA the pairing was made with. A Host
|
||||
// keeps its CA with its signing key from its first boot, so a different
|
||||
// one means a different Host, or someone else answering on its socket.
|
||||
if existing != nil && existing.CACert != "" && !sameCAs(existing.CACert, rr.caCert()) {
|
||||
return fmt.Errorf("the Host answered with a different CA than the one %s was paired with — refusing. "+
|
||||
"A Host keeps its CA for life; to pair a new one, `privatae-bastion unpair %s` first", existing.HostName, existing.HostName)
|
||||
}
|
||||
// One runs directory per pairing: the sockets in it are the runs of the
|
||||
// pairing that owns it, and a second pairing sharing it could reach them.
|
||||
for _, o := range others {
|
||||
if existing != nil && o.HostName == existing.HostName {
|
||||
continue
|
||||
}
|
||||
if o.unixTransport() && (o.RunsDir == rr.RunsDir || o.LaneSocket == rr.LaneSocket) {
|
||||
return fmt.Errorf("%s already uses that lane socket or runs directory; each Host on this machine needs its own", o.HostName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceKeepsTransport: a --replace rotates credentials over the same
|
||||
// transport ("" the platform, "unix" or "tcp"). A unix pairing re-pairs over
|
||||
// its Host's socket, a tcp one on its Host's enroll port, a platform one
|
||||
// through the platform; crossing over is a different pairing.
|
||||
func replaceKeepsTransport(existing *Config, transport string) error {
|
||||
if existing == nil || existing.Transport == transport {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case existing.unixTransport():
|
||||
return fmt.Errorf("%s is paired with a Host on this machine — re-pair it with `pair --replace --redeem-url unix://<its enroll socket> <token>`", existing.HostName)
|
||||
case existing.tcpTransport():
|
||||
return fmt.Errorf("%s is paired with a Host over TCP — re-pair it with `pair --replace --redeem-url https://<its enroll port> --host-ca <its CA> <token>`", existing.HostName)
|
||||
}
|
||||
return fmt.Errorf("%s is paired through the platform — a --redeem-url cannot replace it; `privatae-bastion unpair %s` first", existing.HostName, existing.HostName)
|
||||
}
|
||||
|
||||
// unixPairingConfig builds the pairing a unix answer makes. Per-host state is
|
||||
// allocated exactly as for a platform pairing; a --replace keeps it.
|
||||
func unixPairingConfig(rr unixRedeemResp, cfgs []*Config, existing *Config, replace bool,
|
||||
wgPriv, opKey, fp string) (*Config, error) {
|
||||
opSecret, err := genSecret(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cd, err := configDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host := rr.hostName()
|
||||
cfg := &Config{
|
||||
HostName: host,
|
||||
// Never dialled: kept so a re-pair shows as a new key (superviseOnce).
|
||||
WGPrivateKey: wgPriv,
|
||||
OperatorCert: rr.operatorCert(), OperatorKey: opKey, CACert: rr.caCert(),
|
||||
Bearer: rr.Bearer, OperatorSecret: opSecret, Fingerprint: fp,
|
||||
Transport: transportUnix, LaneSocket: rr.LaneSocket, RunsDir: rr.RunsDir,
|
||||
ListenAddr: nextListenAddr(cfgs),
|
||||
GitProxyAddr: nextGitProxyAddr(cfgs),
|
||||
ContextRoot: filepath.Join(cd, "context", host),
|
||||
DCNDir: filepath.Join(cd, "dcn", host),
|
||||
}
|
||||
if replace && existing != nil {
|
||||
cfg.ListenAddr = existing.ListenAddr
|
||||
if existing.GitProxyAddr != "" {
|
||||
cfg.GitProxyAddr = existing.GitProxyAddr
|
||||
}
|
||||
cfg.ContextRoot = existing.ContextRoot
|
||||
cfg.DCNDir = existing.DCNDir
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
Reference in New Issue
Block a user