hub: S1 wgsync (pinned-SSH push + declarative reconciler) + /admin/wg API + env wiring
internal/wgsync: x/crypto/ssh client with ssh.FixedHostKey pin (no insecure fallback), forced-command exec, ok/applied response contract; Reconciler pushes the FULL peer list on Trigger or 5-min tick (drift repair by construction). internal/api/wg.go: PUT/GET /admin/wg/endpoint + POST/DELETE/GET /admin/wg/peers, global-key-only, pubkey in body (base64 vs URL), sync ok|deferred|disabled. main.go: WG_ENDPOINT_SSH_* env wiring, disabled-with-INFO when unconfigured. Groups B/C/D tests incl. in-process SSH server; red-proofs b/c/d run + reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
package wgsync
|
||||
|
||||
// Group C — the SSH push client against an IN-PROCESS x/crypto/ssh server (Scenarios A/B/C-c4).
|
||||
// The server captures the exact stdin payload, so tests assert the pushed BYTES; the host-key
|
||||
// tests prove the pin both ways (correct key accepted, wrong key refused).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// testKeys generates an ed25519 keypair and returns (PEM private key, ssh.Signer).
|
||||
func testKeys(t *testing.T) ([]byte, ssh.Signer) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519: %v", err)
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "")
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPrivateKey: %v", err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSignerFromKey: %v", err)
|
||||
}
|
||||
return pem.EncodeToMemory(block), signer
|
||||
}
|
||||
|
||||
// testServer is a single-shot in-process SSH server handling one exec session.
|
||||
type testServer struct {
|
||||
addr string
|
||||
mu sync.Mutex
|
||||
captured []byte // stdin the "script" received
|
||||
cmd string // the exec command string requested
|
||||
}
|
||||
|
||||
// startTestServer runs an SSH server that accepts clientSigner's key, serves with hostSigner,
|
||||
// reads all stdin, replies stdoutResp/stderrResp and exitStatus.
|
||||
func startTestServer(t *testing.T, hostSigner ssh.Signer, clientSigner ssh.Signer,
|
||||
stdoutResp, stderrResp string, exitStatus uint32) *testServer {
|
||||
t.Helper()
|
||||
cfg := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
if bytes.Equal(key.Marshal(), clientSigner.PublicKey().Marshal()) {
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
return nil, io.EOF
|
||||
},
|
||||
}
|
||||
cfg.AddHostKey(hostSigner)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
srv := &testServer{addr: ln.Addr().String()}
|
||||
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sconn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
return // e.g. the wrong-host-key client aborts the handshake
|
||||
}
|
||||
defer sconn.Close()
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for newCh := range chans {
|
||||
if newCh.ChannelType() != "session" {
|
||||
newCh.Reject(ssh.UnknownChannelType, "unsupported")
|
||||
continue
|
||||
}
|
||||
ch, chReqs, err := newCh.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
for req := range chReqs {
|
||||
if req.Type == "exec" {
|
||||
var p struct{ Command string }
|
||||
ssh.Unmarshal(req.Payload, &p)
|
||||
srv.mu.Lock()
|
||||
srv.cmd = p.Command
|
||||
srv.mu.Unlock()
|
||||
req.Reply(true, nil)
|
||||
data, _ := io.ReadAll(ch) // the pushed payload (client EOFs stdin)
|
||||
srv.mu.Lock()
|
||||
srv.captured = data
|
||||
srv.mu.Unlock()
|
||||
if stderrResp != "" {
|
||||
ch.Stderr().Write([]byte(stderrResp))
|
||||
}
|
||||
if stdoutResp != "" {
|
||||
ch.Write([]byte(stdoutResp))
|
||||
}
|
||||
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{exitStatus}))
|
||||
ch.Close()
|
||||
return
|
||||
}
|
||||
req.Reply(false, nil)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return srv
|
||||
}
|
||||
|
||||
func hostKeyLine(t *testing.T, s ssh.Signer) string {
|
||||
t.Helper()
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey())))
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, addr, hostKey string, clientPEM []byte) *Client {
|
||||
t.Helper()
|
||||
c, err := New(Config{
|
||||
Addr: addr, User: "felhom-peersync", PrivateKey: clientPEM,
|
||||
HostKeyLine: hostKey, Timeout: 5 * time.Second,
|
||||
}, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("wgsync.New: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPush_PayloadDeliveredExactly(t *testing.T) {
|
||||
clientPEM, clientSigner := testKeys(t)
|
||||
_, hostSigner := testKeys(t)
|
||||
srv := startTestServer(t, hostSigner, clientSigner, `{"status":"ok","applied":1}`, "", 0)
|
||||
c := newTestClient(t, srv.addr, hostKeyLine(t, hostSigner), clientPEM)
|
||||
|
||||
payload := []byte(`{"version":1,"interface":"wg0","peers":[{"pubkey":"PK1","allowed_ip":"10.77.0.2/32"}]}`)
|
||||
if err := c.Push(context.Background(), payload); err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if !bytes.Equal(srv.captured, payload) {
|
||||
t.Errorf("server captured %q, want the exact payload %q", srv.captured, payload)
|
||||
}
|
||||
if srv.cmd != "felhom-peersync" {
|
||||
t.Errorf("exec command = %q, want felhom-peersync (documents intent even under forced command)", srv.cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_RemoteFailureSurfacesStderr(t *testing.T) {
|
||||
clientPEM, clientSigner := testKeys(t)
|
||||
_, hostSigner := testKeys(t)
|
||||
srv := startTestServer(t, hostSigner, clientSigner, "", "boom", 1)
|
||||
c := newTestClient(t, srv.addr, hostKeyLine(t, hostSigner), clientPEM)
|
||||
|
||||
err := c.Push(context.Background(), []byte(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("Push succeeded against exit-1 server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("error %q does not carry the remote stderr", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_MalformedResponseIsError(t *testing.T) {
|
||||
clientPEM, clientSigner := testKeys(t)
|
||||
_, hostSigner := testKeys(t)
|
||||
srv := startTestServer(t, hostSigner, clientSigner, "garbage-not-json", "", 0)
|
||||
c := newTestClient(t, srv.addr, hostKeyLine(t, hostSigner), clientPEM)
|
||||
|
||||
if err := c.Push(context.Background(), []byte(`{}`)); err == nil {
|
||||
t.Fatal("Push accepted a malformed script response — it must not guess")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPush_WrongHostKeyRefused(t *testing.T) {
|
||||
clientPEM, clientSigner := testKeys(t)
|
||||
_, hostSigner := testKeys(t)
|
||||
_, otherSigner := testKeys(t) // NOT the server's key
|
||||
srv := startTestServer(t, hostSigner, clientSigner, `{"status":"ok","applied":0}`, "", 0)
|
||||
c := newTestClient(t, srv.addr, hostKeyLine(t, otherSigner), clientPEM)
|
||||
|
||||
err := c.Push(context.Background(), []byte(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("Push succeeded against a server with the WRONG host key — the pin is dead")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "host key") && !strings.Contains(err.Error(), "handshake") {
|
||||
t.Errorf("error %q is not a host-key refusal", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if len(srv.captured) != 0 {
|
||||
t.Errorf("payload leaked to a mis-keyed server: %q", srv.captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_BadInputsFailEarly(t *testing.T) {
|
||||
clientPEM, _ := testKeys(t)
|
||||
_, hostSigner := testKeys(t)
|
||||
hk := hostKeyLine(t, hostSigner)
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
|
||||
if _, err := New(Config{Addr: "x:22", User: "u", PrivateKey: []byte("not-a-key"), HostKeyLine: hk}, logger); err == nil {
|
||||
t.Error("bad private key accepted")
|
||||
}
|
||||
if _, err := New(Config{Addr: "x:22", User: "u", PrivateKey: clientPEM, HostKeyLine: "not a key line"}, logger); err == nil {
|
||||
t.Error("bad host key line accepted")
|
||||
}
|
||||
if _, err := New(Config{User: "u", PrivateKey: clientPEM, HostKeyLine: hk}, logger); err == nil {
|
||||
t.Error("missing addr accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user