- internal/agent: outbound WSS to core with SPKI pin checked inside the TLS handshake, Ed25519 challenge-response, one-shot join secret (dropped from memory and cli.env after the first ready), forwarding of core requests to the local awg-ctrl with an internal token, dead-connection timer on pings, backoff up to a minute (a minute after 4003) - path filter is stricter than the Node one where it relied on WHATWG URL normalisation: backslashes, a leading "//" and %2e dot segments are rejected - tests: end-to-end against a fake TLS core and a fake awg-ctrl; also checked by hand against the real awg-ui/nodes.ts hub - CI: Go build, vet and race tests on GitHub and Gitea
271 lines
8.5 KiB
Go
271 lines
8.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/maeneko/forgetting/internal/iauth"
|
|
"github.com/maeneko/forgetting/internal/jsjson"
|
|
)
|
|
|
|
func TestPathAllowed(t *testing.T) {
|
|
for p, want := range map[any]bool{
|
|
"/health": true,
|
|
"/api/users": true,
|
|
"/api/users/a?x=1": true,
|
|
"/awg/upgrade": true,
|
|
"/healthz": false,
|
|
"/api": false,
|
|
"/etc/passwd": false,
|
|
"//evil.com/api/x": false,
|
|
`/\evil.com/api/x`: false,
|
|
"/api/../etc": false,
|
|
"/api/%2e%2E/etc": false,
|
|
"/api/./x": false,
|
|
"/api/a b": false,
|
|
"api/users": false,
|
|
42: false,
|
|
"/api/" + strings.Repeat("a", 2048): false,
|
|
} {
|
|
if _, got := pathAllowed(p); got != want {
|
|
t.Errorf("pathAllowed(%v) = %v, want %v", p, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeKey(t *testing.T, file string, priv ed25519.PrivateKey) {
|
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.WriteFile(file, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
|
|
}
|
|
|
|
type core struct {
|
|
srv *httptest.Server
|
|
frames chan jsjson.Object // всё, что прислал агент
|
|
script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)
|
|
}
|
|
|
|
func newCore(t *testing.T, script func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object)) *core {
|
|
c := &core{frames: make(chan jsjson.Object, 16), script: script}
|
|
c.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/node/v1" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
ws, err := websocket.Accept(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ws.CloseNow()
|
|
ctx := r.Context()
|
|
read := func() jsjson.Object {
|
|
_, raw, err := ws.Read(ctx)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
v, _ := jsjson.Decode(raw)
|
|
o, _ := v.(jsjson.Object)
|
|
c.frames <- o
|
|
return o
|
|
}
|
|
c.script(ctx, ws, read)
|
|
}))
|
|
t.Cleanup(c.srv.Close)
|
|
return c
|
|
}
|
|
|
|
func (c *core) pin(t *testing.T) string {
|
|
p, err := spkiPin(c.srv.Certificate().Raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (c *core) hostPort() (string, int) {
|
|
u, _ := url.Parse(c.srv.URL)
|
|
port, _ := strconv.Atoi(u.Port())
|
|
return u.Hostname(), port
|
|
}
|
|
|
|
func newAgent(t *testing.T, c *core, pin, ctrlURL string) (*Agent, string) {
|
|
dir := t.TempDir()
|
|
host, port := c.hostPort()
|
|
_, ik, _ := ed25519.GenerateKey(rand.Reader)
|
|
writeKey(t, filepath.Join(dir, "internal.key"), ik)
|
|
envFile := filepath.Join(dir, "cli.env")
|
|
os.WriteFile(envFile, []byte("CORE_HOST=x\nJOIN_SECRET=s3cret\nNODE_ID=7\n"), 0o600)
|
|
a, err := New(Config{
|
|
CoreHost: host, CorePort: port, CorePin: pin, NodeID: 7, Ctrl: ctrlURL,
|
|
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
|
|
CLIEnvFile: envFile, JoinSecret: "s3cret", Version: "Beta 0.3.4",
|
|
Logf: t.Logf, Errorf: t.Logf,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return a, envFile
|
|
}
|
|
|
|
func send(ctx context.Context, ws *websocket.Conn, v any) {
|
|
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(v))
|
|
}
|
|
|
|
func TestHandshakeAndRequests(t *testing.T) {
|
|
var internalPub ed25519.PublicKey
|
|
ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if !iauth.Verify(internalPub, tok, time.Now()) {
|
|
w.WriteHeader(401)
|
|
io.WriteString(w, `{"error":"Неверная авторизация"}`)
|
|
return
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
switch r.URL.Path {
|
|
case "/api/users":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(201)
|
|
io.WriteString(w, `{"got":`+string(body)+`, "q":"`+r.URL.RawQuery+`"}`)
|
|
case "/health":
|
|
io.WriteString(w, "ok")
|
|
default:
|
|
w.WriteHeader(204)
|
|
}
|
|
}))
|
|
defer ctrl.Close()
|
|
|
|
results := make(chan jsjson.Object, 8)
|
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n0nce"}})
|
|
auth := read()
|
|
if auth == nil {
|
|
return
|
|
}
|
|
join, _ := auth.Get2("join").(jsjson.Object)
|
|
pubRaw, _ := b64.DecodeString(join.Get2("pub").(string))
|
|
sig, _ := b64.DecodeString(auth.Get2("sig").(string))
|
|
if !ed25519.Verify(pubRaw, authMsg("n0nce", 7), sig) {
|
|
ws.Close(4003, "unauthorized")
|
|
return
|
|
}
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}})
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "post"},
|
|
{Key: "path", Val: "/api/users?x=1"}, {Key: "body", Val: jsjson.Object{{Key: "name", Val: "alice"}}}})
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/etc/passwd"}})
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/health"}})
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 4}, {Key: "method", Val: "DELETE"}, {Key: "path", Val: "/api/x"}})
|
|
for range 4 {
|
|
if r := read(); r != nil {
|
|
results <- r
|
|
}
|
|
}
|
|
ws.Close(websocket.StatusNormalClosure, "")
|
|
})
|
|
|
|
a, envFile := newAgent(t, c, c.pin(t), ctrl.URL)
|
|
internalPub = a.internalKey.Public().(ed25519.PublicKey)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if a.connectOnce(ctx, nil) {
|
|
t.Fatal("authFailed on a good handshake")
|
|
}
|
|
|
|
auth := <-c.frames
|
|
want := `{"t":"auth","node_id":7,"sig":"` + auth.Get2("sig").(string) + `","join":{"secret":"s3cret","pub":"` + a.nodePubRaw + `"},"version":"Beta 0.3.4"}`
|
|
if got := string(jsjson.MustMarshal(auth)); got != want {
|
|
t.Errorf("auth frame:\n got %s\nwant %s", got, want)
|
|
}
|
|
|
|
byID := map[float64]string{}
|
|
for range 4 {
|
|
r := <-results
|
|
byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r))
|
|
}
|
|
for id, want := range map[float64]string{
|
|
1: `{"t":"res","id":1,"status":201,"data":{"got":{"name":"alice"},"q":"x=1"}}`,
|
|
2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`,
|
|
3: `{"t":"res","id":3,"status":200,"data":"ok"}`,
|
|
4: `{"t":"res","id":4,"status":204,"data":""}`,
|
|
} {
|
|
if byID[id] != want {
|
|
t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want)
|
|
}
|
|
}
|
|
|
|
// Секрет одноразовый: после ready он исчезает из памяти и из cli.env.
|
|
if a.secret() != "" {
|
|
t.Error("join secret still in memory")
|
|
}
|
|
if raw, _ := os.ReadFile(envFile); strings.Contains(string(raw), "JOIN_SECRET") || !strings.Contains(string(raw), "NODE_ID=7") {
|
|
t.Errorf("cli.env after join: %q", raw)
|
|
}
|
|
}
|
|
|
|
func TestWrongPinSendsNothing(t *testing.T) {
|
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
|
read()
|
|
})
|
|
a, _ := newAgent(t, c, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "http://127.0.0.1:1")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if a.connectOnce(ctx, nil) {
|
|
t.Fatal("pin mismatch reported as auth failure")
|
|
}
|
|
select {
|
|
case f := <-c.frames:
|
|
t.Fatalf("agent sent %v to a core with a foreign certificate", f)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestAuthRejected(t *testing.T) {
|
|
c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) {
|
|
send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}})
|
|
read()
|
|
ws.Close(4003, "unauthorized")
|
|
})
|
|
a, envFile := newAgent(t, c, c.pin(t), "http://127.0.0.1:1")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if !a.connectOnce(ctx, nil) {
|
|
t.Fatal("4003 not reported as auth failure")
|
|
}
|
|
if raw, _ := os.ReadFile(envFile); !strings.Contains(string(raw), "JOIN_SECRET=s3cret") {
|
|
t.Error("join secret dropped without a successful join")
|
|
}
|
|
}
|
|
|
|
func TestNodeKeyPersists(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "node.key")
|
|
k1, err := loadNodeKey(file, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k2, _ := loadNodeKey(file, t.Logf)
|
|
if !k1.Equal(k2) {
|
|
t.Fatal("node key regenerated")
|
|
}
|
|
if st, _ := os.Stat(file); st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("node.key mode %v", st.Mode().Perm())
|
|
}
|
|
}
|