910c0fcd76
build / build (push) Failing after 16s
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid, 固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号 - API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态) - worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、 不持有来源凭据 - web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因) - scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
172 lines
4.9 KiB
Go
172 lines
4.9 KiB
Go
package libraryagent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testAgent(t *testing.T) (*Agent, string, func() []string) {
|
|
t.Helper()
|
|
root, err := os.MkdirTemp(os.TempDir(), "kgag-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(root) })
|
|
if err = os.Chmod(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var calls []string
|
|
config := Config{
|
|
Socket: filepath.Join(root, "agent.sock"),
|
|
Binary: "/bin/echo",
|
|
ArchiveRoot: filepath.Join(root, "library"),
|
|
JournalRoot: filepath.Join(root, "freeze"),
|
|
SourceApp: "WKNFull-app",
|
|
SourcePG: "WKNFull-postgres",
|
|
SourceES: "WKNFull-elasticsearch",
|
|
GovernancePG: "knowledge-governance-p1-postgres-1",
|
|
Project: "weknora071full",
|
|
Writers: "app",
|
|
NonWriters: "frontend=UI only; no knowledge writes",
|
|
MaxDuration: time.Minute,
|
|
Run: func(_ context.Context, name string, args ...string) ([]byte, error) {
|
|
mu.Lock()
|
|
calls = append(calls, name+" "+strings.Join(args, " "))
|
|
mu.Unlock()
|
|
return []byte(`{"snapshot_id":"abc","state":"succeeded"}`), nil
|
|
},
|
|
}
|
|
agent, err := New(config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return agent, config.Socket, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), calls...)
|
|
}
|
|
}
|
|
|
|
func roundTrip(t *testing.T, socket string, payload string) response {
|
|
t.Helper()
|
|
conn, err := net.Dial("unix", socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
if _, err = conn.Write([]byte(payload + "\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out response
|
|
if err = json.NewDecoder(conn).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The socket is the whole security boundary: 0600 in a 0700 directory, and the
|
|
// declaration (archive, journal, project, writers) comes from the agent's own
|
|
// configuration, never from the request.
|
|
func TestAgentSocketIsPrivateAndPinsTheDeclaration(t *testing.T) {
|
|
agent, socket, calls := testAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("agent socket never appeared")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
info, err := os.Stat(socket)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0600 {
|
|
t.Fatalf("socket mode %v", info.Mode().Perm())
|
|
}
|
|
dir, err := os.Stat(filepath.Dir(socket))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dir.Mode().Perm() != 0700 {
|
|
t.Fatalf("socket directory mode %v", dir.Mode().Perm())
|
|
}
|
|
out := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-internal-test"}`)
|
|
if !out.OK || out.Job != "lib-internal-test" {
|
|
t.Fatalf("capture rejected: %+v", out)
|
|
}
|
|
recorded := calls()
|
|
if len(recorded) != 1 {
|
|
t.Fatalf("expected one execution, got %v", recorded)
|
|
}
|
|
for _, want := range []string{"capture -scope internal -job lib-internal-test",
|
|
"-archive " + filepath.Join(filepath.Dir(socket), "library"),
|
|
"-journal " + filepath.Join(filepath.Dir(socket), "freeze"),
|
|
"-project weknora071full", "-writers app"} {
|
|
if !strings.Contains(recorded[0], want) {
|
|
t.Fatalf("execution %q missing %q", recorded[0], want)
|
|
}
|
|
}
|
|
if strings.Contains(recorded[0], "..") {
|
|
t.Fatal("unexpected path traversal in declaration")
|
|
}
|
|
}
|
|
|
|
func TestAgentRejectsUnsupportedRequestsWithoutExecuting(t *testing.T) {
|
|
agent, socket, calls := testAgent(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() { _ = agent.Serve(ctx) }()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(socket); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("agent socket never appeared")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
for _, payload := range []string{
|
|
`{"action":"shell","command":"docker ps"}`,
|
|
`{"action":"capture","scope":"production"}`,
|
|
`{"action":"capture","scope":"internal","job":"bad job; rm -rf /"}`,
|
|
`not json`,
|
|
} {
|
|
out := roundTrip(t, socket, payload)
|
|
if out.OK || out.Error == "" {
|
|
t.Fatalf("accepted %q: %+v", payload, out)
|
|
}
|
|
}
|
|
if got := calls(); len(got) != 0 {
|
|
t.Fatalf("rejected requests still executed: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsIncompleteConfiguration(t *testing.T) {
|
|
if _, err := New(Config{}); err == nil {
|
|
t.Fatal("empty configuration accepted")
|
|
}
|
|
if _, err := New(Config{Socket: "relative.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
|
|
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend=ui", MaxDuration: time.Minute}); err == nil {
|
|
t.Fatal("relative socket path accepted")
|
|
}
|
|
if _, err := New(Config{Socket: "/tmp/a.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
|
|
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend", MaxDuration: time.Minute}); err == nil {
|
|
t.Fatal("non-writer declaration without a reason accepted")
|
|
}
|
|
}
|