Files

328 lines
9.6 KiB
Go

// Package join lets a machine be set up without anything logging into it.
//
// The machine doing the setup — running as root, holding the authority —
// listens for a while and hands out one code per machine it expects. On the
// other machine `sudo llmgw join <code>` dials out to it, checks it is talking
// to the listener the code names, proves itself with the code's secret, and
// from then on runs what it is sent, as ssh would, until the setup closes
// the connection. To the setup it is one more onboard.Remote: preflight,
// Patroni and node onboarding run over it unchanged.
//
// For sites where nothing may log into a machine from outside: the
// connection is outbound from the machine being set up, no password is
// typed anywhere, and the code is good for one connection and a limited time.
package join
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"net"
"strings"
"sync"
"time"
)
// DefaultPort is where a setup listens for machines joining it.
const DefaultPort = 7444
// codePrefix marks a join code, and its version.
const codePrefix = "llmgw-join1-"
// Code is what `llmgw join` is given: where to dial, how to know it reached
// the right place, and the secret that admits it.
//
// Short, because somebody copies it from one screen into another: the first
// version was JSON and over 250 characters, which a terminal cut off at its
// edge. Packed it is under 90.
type Code struct {
Addr string
// Fingerprint is the first 16 bytes of the SHA-256 of the listener's
// certificate: 128 bits, more than enough to find nothing else by.
Fingerprint [16]byte
Secret [16]byte
Expires int64
// Role says what the machine is being set up as, for its operator to
// read before it runs: "database", "node" or "control plane".
Role string
}
var roles = []string{"", "database", "node", "control plane"}
// String is the code as typed: one word, safe in a shell.
func (c Code) String() string {
b := make([]byte, 0, 40+len(c.Addr))
b = append(b, c.Fingerprint[:]...)
b = append(b, c.Secret[:]...)
b = binary.BigEndian.AppendUint32(b, uint32(c.Expires))
role := byte(0)
for i, r := range roles {
if r == c.Role {
role = byte(i)
}
}
b = append(b, role)
b = append(b, c.Addr...)
return codePrefix + base64.RawURLEncoding.EncodeToString(b)
}
// Parse reads a code back.
func Parse(s string) (Code, error) {
raw, ok := strings.CutPrefix(strings.TrimSpace(s), codePrefix)
if !ok {
return Code{}, errors.New("not a join code: it starts " + codePrefix)
}
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil || len(b) < 16+16+4+1+3 {
return Code{}, errors.New("the join code is damaged: copy it whole")
}
var c Code
copy(c.Fingerprint[:], b[:16])
copy(c.Secret[:], b[16:32])
c.Expires = int64(binary.BigEndian.Uint32(b[32:36]))
if int(b[36]) < len(roles) {
c.Role = roles[b[36]]
}
c.Addr = string(b[37:])
if _, _, err := net.SplitHostPort(c.Addr); err != nil {
return Code{}, errors.New("the join code is damaged: copy it whole")
}
if time.Now().Unix() > c.Expires {
return Code{}, fmt.Errorf("the join code expired at %s; ask for a new one",
time.Unix(c.Expires, 0).Format(time.RFC3339))
}
return c, nil
}
// The wire: one JSON object a line, each way.
type hello struct {
Secret []byte `json:"secret"`
Hostname string `json:"hostname"`
// Addr is the address the machine dialled out from: how the others
// reach it, when nothing else says.
Addr string `json:"addr"`
}
type request struct {
Script string `json:"script,omitempty"`
Stdin []byte `json:"stdin,omitempty"`
Bye bool `json:"bye,omitempty"`
}
type response struct {
Stdout []byte `json:"stdout,omitempty"`
Stderr []byte `json:"stderr,omitempty"`
Exit int `json:"exit"`
Error string `json:"error,omitempty"`
}
// Listener waits for machines to join.
type Listener struct {
ln net.Listener
addr string
// now is time.Now, replaced by tests.
now func() time.Time
fingerprint [16]byte
ttl time.Duration
mu sync.Mutex
pending map[[16]byte]waiting // by secret
}
// waiting is a code not yet used: where its machine is handed over, and
// until when it may come.
type waiting struct {
ch chan *Remote
expires time.Time
}
// Listen starts listening on bind (":7444") and advertises addr ("10.0.0.5:7444")
// in its codes. Codes are good for ttl.
func Listen(bind, addr string, ttl time.Duration) (*Listener, error) {
cert, fp, err := sessionCert()
if err != nil {
return nil, err
}
ln, err := tls.Listen("tcp", bind, &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13})
if err != nil {
return nil, fmt.Errorf("listening for machines to join on %s: %w", bind, err)
}
l := &Listener{ln: ln, addr: addr, fingerprint: fp, ttl: ttl, pending: map[[16]byte]waiting{}, now: time.Now}
go l.accept()
return l, nil
}
// sessionCert is a certificate for this listener alone, trusted by the
// fingerprint in its codes rather than by any authority: it lives as long as
// the setup does.
func sessionCert() (tls.Certificate, [16]byte, error) {
var fp [16]byte
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, fp, err
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "llmgw setup"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(48 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, fp, err
}
sum := sha256.Sum256(der)
copy(fp[:], sum[:16])
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, fp, nil
}
// Code makes a code for one machine, and the channel its connection arrives
// on.
func (l *Listener) Code(role string) (Code, <-chan *Remote) {
var secret [16]byte
_, _ = rand.Read(secret[:])
ch := make(chan *Remote, 1)
expires := time.Now().Add(l.ttl)
l.mu.Lock()
l.pending[secret] = waiting{ch: ch, expires: expires}
l.mu.Unlock()
return Code{Addr: l.addr, Fingerprint: l.fingerprint, Secret: secret,
Expires: expires.Unix(), Role: role}, ch
}
// Close stops listening; machines already joined stay connected until their
// Remote is closed.
func (l *Listener) Close() error { return l.ln.Close() }
func (l *Listener) accept() {
for {
c, err := l.ln.Accept()
if err != nil {
return
}
go l.admit(c)
}
}
func (l *Listener) admit(c net.Conn) {
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
r := bufio.NewReader(c)
var h hello
line, err := r.ReadBytes('\n')
if err != nil || json.Unmarshal(line, &h) != nil {
_ = c.Close()
return
}
l.mu.Lock()
var ch chan *Remote
expired := false
now := l.now()
for secret, p := range l.pending {
if subtle.ConstantTimeCompare(secret[:], h.Secret) == 1 {
// One machine a code: whoever comes second is turned away. And
// not after it expires, which the joining side checking was no
// guarantee of: a code is a credential for as long as this
// listener runs, unless this side looks too.
delete(l.pending, secret)
if now.After(p.expires) {
expired = true
} else {
ch = p.ch
}
}
}
l.mu.Unlock()
if ch == nil {
msg := "this code is not one the setup is waiting for, or it was used already"
if expired {
msg = "this code expired; ask for a new one"
}
_, _ = c.Write([]byte(`{"error":"` + msg + `"}` + "\n"))
_ = c.Close()
return
}
_ = c.SetDeadline(time.Time{})
if _, err := c.Write([]byte("{}\n")); err != nil {
_ = c.Close()
return
}
// The address it connected from, not the one it says it has: the setup
// lets that address into the store, and a claim is anyone's to make.
addr, _, _ := net.SplitHostPort(c.RemoteAddr().String())
ch <- &Remote{conn: c, r: r, Hostname: h.Hostname, Addr: addr, Claimed: h.Addr}
}
// Remote is a joined machine, as the setup runs things on it.
type Remote struct {
Hostname string
// Addr is where it connected from; Claimed what it said its address is,
// for showing beside it when the two differ.
Addr string
Claimed string
mu sync.Mutex
conn net.Conn
r *bufio.Reader
}
// Run runs script with sh on the machine, as root.
func (m *Remote) Run(ctx context.Context, script string, stdin []byte) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.conn == nil {
return nil, fmt.Errorf("%s is no longer connected", m.Hostname)
}
if dl, ok := ctx.Deadline(); ok {
_ = m.conn.SetDeadline(dl)
defer m.conn.SetDeadline(time.Time{})
}
b, _ := json.Marshal(request{Script: script, Stdin: stdin})
if _, err := m.conn.Write(append(b, '\n')); err != nil {
return nil, fmt.Errorf("%s: %w", m.Hostname, err)
}
line, err := m.r.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("%s went away: %w", m.Hostname, err)
}
var resp response
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("%s answered something unreadable", m.Hostname)
}
if resp.Error != "" {
return append(resp.Stdout, resp.Stderr...), errors.New(resp.Error)
}
if resp.Exit != 0 {
return append(resp.Stdout, resp.Stderr...), fmt.Errorf("exit status %d", resp.Exit)
}
return resp.Stdout, nil
}
// Close tells the machine the setup is done, which ends its `llmgw join`.
func (m *Remote) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.conn == nil {
return nil
}
b, _ := json.Marshal(request{Bye: true})
_, _ = m.conn.Write(append(b, '\n'))
err := m.conn.Close()
m.conn = nil
return err
}