chore: include complete dc refactor implementation and audits
Release / Build & Release (push) Canceled after 0s

This commit is contained in:
kory
2026-10-02 13:23:30 +08:00
parent 192969d7f6
commit 38382d2486
22 changed files with 3022 additions and 8 deletions
+48 -2
View File
@@ -1,7 +1,10 @@
package api
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
@@ -59,8 +62,8 @@ func TestSecurityEffectiveModeAndExecGate(t *testing.T) {
}
defer store.DB.Close()
s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"}
if got := s.GetEffectiveSecurityMode("node-a"); got != "medium" {
t.Fatalf("default mode=%s", got)
if got := s.GetEffectiveSecurityMode("node-a"); got != "unknown" {
t.Fatalf("unknown mode=%s", got)
}
if err := store.SetNodeSecurityMode("node-a", "high"); err != nil {
t.Fatal(err)
@@ -88,3 +91,46 @@ func TestSecurityEffectiveModeAndExecGate(t *testing.T) {
t.Fatalf("missing denial audit: %+v", logs)
}
}
func TestEditBlockSecurityAndReplacement(t *testing.T) {
dir := t.TempDir()
store, err := storage.Open(filepath.Join(dir, "db.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("medium"); err != nil {
t.Fatal(err)
}
s := &Server{Exec: executor.Local{MaxOutput: 1024 * 1024}, Store: store, LocalName: "local"}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "config.txt")
if err := os.WriteFile(path, []byte("alpha\nTARGET\nomega\n"), 0644); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "/api/v1/devices/file/edit_block", strings.NewReader(fmt.Sprintf(`{"path":%q,"old_text":"TARGET","new_text":"CHANGED"}`, path)))
w := httptest.NewRecorder()
s.editBlock(w, r)
if w.Code != 200 {
t.Fatalf("edit status=%d body=%s", w.Code, w.Body.String())
}
b, _ := os.ReadFile(path)
if string(b) != "alpha\nCHANGED\nomega\n" {
t.Fatalf("unexpected content: %q", b)
}
if err := store.SetGlobalSecurityMode("low"); err != nil {
t.Fatal(err)
}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
r = httptest.NewRequest(http.MethodPost, "/api/v1/devices/file/edit_block", strings.NewReader(fmt.Sprintf(`{"path":%q,"old_text":"CHANGED","new_text":"NO"}`, path)))
w = httptest.NewRecorder()
s.editBlock(w, r)
if w.Code != 403 {
t.Fatalf("low mode status=%d body=%s", w.Code, w.Body.String())
}
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"context"
"path/filepath"
"testing"
"github.com/wjyhk/vps-commander/internal/executor"
"github.com/wjyhk/vps-commander/internal/storage"
)
func TestMCPBackendHonorsSecuritySnapshot(t *testing.T) {
store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("low"); err != nil {
t.Fatal(err)
}
s := &Server{Store: store, LocalName: "local", Exec: executor.Local{MaxOutput: 1024 * 1024}}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
b := s.NewMCPBackend()
if _, err := b.ExecCommand(context.Background(), "test", "local", "true", "", 5); err == nil {
t.Fatal("MCP exec must be denied in low mode")
}
if _, err := b.ReadFile(context.Background(), "test", "local", "/etc/hosts", 0, 128); err != nil {
t.Fatalf("MCP read should remain available in low mode: %v", err)
}
}
func TestUnknownDeviceFailsClosed(t *testing.T) {
store, err := storage.Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer store.DB.Close()
if err := store.SetGlobalSecurityMode("high"); err != nil {
t.Fatal(err)
}
s := &Server{Store: store, LocalName: "local", Exec: executor.Local{MaxOutput: 1024 * 1024}}
if err := s.LoadSecurityPolicySnapshot(); err != nil {
t.Fatal(err)
}
if got := s.GetEffectiveSecurityMode("not-registered"); got != "unknown" {
t.Fatalf("unknown device mode=%q", got)
}
b := s.NewMCPBackend()
if _, err := b.ExecCommand(context.Background(), "test", "not-registered", "true", "", 5); err == nil {
t.Fatal("unknown device must not inherit high risk")
}
}
+130
View File
@@ -616,6 +616,136 @@
}
}
}
},
"/api/v1/devices/file/edit_block": {
"post": {
"summary": "Atomically replace a unique text block on a device",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path",
"old_text",
"new_text"
],
"properties": {
"device": {
"type": "string"
},
"path": {
"type": "string"
},
"old_text": {
"type": "string"
},
"new_text": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Edited"
},
"403": {
"description": "Security policy denied"
},
"409": {
"description": "Device offline or edit conflict"
},
"413": {
"description": "Edit block too large"
}
}
}
},
"/api/v1/process/session": {
"post": {
"summary": "Manage long-running process sessions",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "action",
"in": "query",
"required": true,
"schema": {
"type": "string",
"enum": [
"start",
"read",
"input",
"stop",
"list"
]
}
}
],
"responses": {
"200": {
"description": "Session operation result"
}
}
}
},
"/api/v1/file/list": {
"post": {
"summary": "List directory",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
},
"/api/v1/file/info": {
"post": {
"summary": "Get file metadata",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
},
"/api/v1/file/search": {
"post": {
"summary": "Search files",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Successful result"
}
}
}
}
}
}
+1 -6
View File
@@ -42,12 +42,7 @@ func (m Middleware) Handler(next http.Handler) http.Handler {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
got = strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
} else if q := r.URL.Query().Get("token"); q != "" {
got = q
} else if q := r.URL.Query().Get("key"); q != "" {
got = q
} else if q := r.URL.Query().Get("api_key"); q != "" {
got = q
}
expected := m.Token
+52
View File
@@ -0,0 +1,52 @@
package cluster
import (
"context"
"sync"
"testing"
"time"
)
func TestNodeOperationGateAllowsReadsAndSerializesWrites(t *testing.T) {
n := &Node{}
release1 := n.acquireOperation("file_read")
done := make(chan struct{})
go func() { release2 := n.acquireOperation("edit_block"); close(done); release2() }()
select {
case <-done:
t.Fatal("write entered while read active")
case <-time.After(50 * time.Millisecond):
}
release1()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("write did not proceed")
}
}
func TestNodeOperationGateAllowsConcurrentReads(t *testing.T) {
n := &Node{}
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
release := n.acquireOperation("file_read")
time.Sleep(30 * time.Millisecond)
release()
}()
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("reads did not complete")
}
}
func TestOperationRiskUnknownFailsClosed(t *testing.T) {
if operationRisk("unknown-danger") != 3 {
t.Fatal("unknown action must be high risk")
}
_ = context.Background()
}
+130
View File
@@ -0,0 +1,130 @@
package executor
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var ErrBlockNotUnique = errors.New("old_text must occur exactly once")
var ErrEditConflict = errors.New("file changed during edit")
var editMu sync.Mutex
type EditBlockResult struct {
BytesWritten int64
}
func EditBlock(path, oldText, newText string, expectedHash ...string) (EditBlockResult, error) {
editMu.Lock()
defer editMu.Unlock()
if path == "" {
return EditBlockResult{}, errors.New("path required")
}
if oldText == "" {
return EditBlockResult{}, errors.New("old_text required")
}
data, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
initial := sha256.Sum256(data)
initialHash := hex.EncodeToString(initial[:])
if len(expectedHash) > 0 && expectedHash[0] != "" && expectedHash[0] != initialHash {
return EditBlockResult{}, ErrEditConflict
}
src := string(data)
count := countNonOverlapping(src, oldText)
if count != 1 {
if count == 0 {
return EditBlockResult{}, fmt.Errorf("%w: found 0 matches", ErrBlockNotUnique)
}
return EditBlockResult{}, fmt.Errorf("%w: found %d matches", ErrBlockNotUnique, count)
}
updated := replaceOnce(src, oldText, newText)
info, err := os.Stat(path)
if err != nil {
return EditBlockResult{}, err
}
// Re-read immediately before commit. This is the CAS check against
// writers outside this process; editMu serializes our own writers.
latest, err := os.ReadFile(path)
if err != nil {
return EditBlockResult{}, err
}
latestSum := sha256.Sum256(latest)
if hex.EncodeToString(latestSum[:]) != initialHash {
return EditBlockResult{}, ErrEditConflict
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".vps-commander-edit-*")
if err != nil {
return EditBlockResult{}, err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if _, err := tmp.WriteString(updated); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return EditBlockResult{}, err
}
if err := tmp.Close(); err != nil {
return EditBlockResult{}, err
}
if err := os.Rename(tmpName, path); err != nil {
return EditBlockResult{}, err
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return EditBlockResult{BytesWritten: int64(len(updated))}, nil
}
func countNonOverlapping(src, old string) int {
n, count := 0, 0
for {
i := indexString(src[n:], old)
if i < 0 {
return count
}
count++
n += i + len(old)
if n > len(src) {
return count
}
}
}
func replaceOnce(src, old, new string) string {
i := indexString(src, old)
return src[:i] + new + src[i+len(old):]
}
func indexString(src, needle string) int {
if len(needle) == 0 {
return 0
}
for i := 0; i+len(needle) <= len(src); i++ {
if src[i:i+len(needle)] == needle {
return i
}
}
return -1
}
+52
View File
@@ -0,0 +1,52 @@
package executor
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestEditBlockUniqueAndAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.txt")
if err := os.WriteFile(path, []byte("a\nTARGET\nz\n"), 0640); err != nil {
t.Fatal(err)
}
res, err := EditBlock(path, "TARGET", "CHANGED")
if err != nil {
t.Fatal(err)
}
if res.BytesWritten != int64(len("a\nCHANGED\nz\n")) {
t.Fatalf("bytes=%d", res.BytesWritten)
}
got, _ := os.ReadFile(path)
if string(got) != "a\nCHANGED\nz\n" {
t.Fatalf("content=%q", got)
}
info, _ := os.Stat(path)
if info.Mode().Perm() != 0640 {
t.Fatalf("mode=%o, want 0640", info.Mode().Perm())
}
}
func TestEditBlockRejectsZeroAndMultipleMatchesWithoutMutation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.txt")
original := "TARGET\nother\nTARGET\n"
if err := os.WriteFile(path, []byte(original), 0644); err != nil {
t.Fatal(err)
}
_, err := EditBlock(path, "MISSING", "x")
if !errors.Is(err, ErrBlockNotUnique) {
t.Fatalf("missing match error=%v", err)
}
_, err = EditBlock(path, "TARGET", "x")
if !errors.Is(err, ErrBlockNotUnique) {
t.Fatalf("multiple match error=%v", err)
}
got, _ := os.ReadFile(path)
if string(got) != original {
t.Fatalf("file mutated after rejected edit: %q", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
package executor
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestListDirectoryAndInfo(t *testing.T) {
d := t.TempDir()
if err := os.WriteFile(filepath.Join(d, "a.txt"), []byte("hello"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(d, "sub"), 0700); err != nil {
t.Fatal(err)
}
es, err := ListDirectory(d, false, 10)
if err != nil || len(es) != 2 {
t.Fatalf("entries=%v err=%v", es, err)
}
fi, err := GetFileInfo(filepath.Join(d, "a.txt"))
if err != nil || fi.Size != 5 {
t.Fatalf("info=%+v err=%v", fi, err)
}
}
func TestSearchHonorsContextAndLimit(t *testing.T) {
d := t.TempDir()
for i := 0; i < 3; i++ {
_ = os.WriteFile(filepath.Join(d, string(rune('a'+i))+".txt"), []byte("needle\n"), 0600)
}
r, err := Search(context.Background(), d, "needle", 2)
if err != nil || len(r) != 2 {
t.Fatalf("results=%v err=%v", r, err)
}
}
+248
View File
@@ -0,0 +1,248 @@
package executor
import (
"context"
"errors"
"io"
"os/exec"
"sync"
"syscall"
"time"
)
const (
DefaultRingCapacity = 2 * 1024 * 1024
MaxSessionInput = 64 * 1024
DefaultSessionTTL = 30 * time.Minute
SessionIdleTTL = 10 * time.Minute
MaxSessionBufferTotal = 8 * 1024 * 1024
)
type RingBuffer struct {
mu sync.RWMutex
buf []byte
head int
size int
}
func NewRingBuffer(capacity int) *RingBuffer {
if capacity <= 0 {
capacity = DefaultRingCapacity
}
return &RingBuffer{buf: make([]byte, capacity)}
}
func (r *RingBuffer) Write(p []byte) (int, error) {
total := len(p)
r.mu.Lock()
defer r.mu.Unlock()
for len(p) > 0 {
n := len(p)
if n > len(r.buf) {
p = p[n-len(r.buf):]
n = len(p)
}
first := len(r.buf) - r.head
if first > n {
first = n
}
copy(r.buf[r.head:r.head+first], p[:first])
if first < n {
copy(r.buf[:n-first], p[first:n])
}
r.head = (r.head + n) % len(r.buf)
r.size += n
if r.size > len(r.buf) {
r.size = len(r.buf)
}
p = p[n:]
}
return total, nil
}
func (r *RingBuffer) Snapshot() []byte {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]byte, r.size)
start := (r.head - r.size + len(r.buf)) % len(r.buf)
first := len(r.buf) - start
if first > r.size {
first = r.size
}
copy(out, r.buf[start:start+first])
if first < r.size {
copy(out[first:], r.buf[:r.size-first])
}
return out
}
type Session struct {
ID string
Command string
Owner string
StartedAt time.Time
ExpiresAt time.Time
LastAccess time.Time
cmd *exec.Cmd
stdin io.WriteCloser
stdout *RingBuffer
stderr *RingBuffer
done chan struct{}
mu sync.RWMutex
err error
exitCode int
}
func StartSession(ctx context.Context, id, command, workdir, owner string, capacity int) (*Session, error) {
if id == "" || command == "" {
return nil, errors.New("session id and command required")
}
cmd := exec.Command("/bin/sh", "-c", command)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if workdir != "" {
cmd.Dir = workdir
}
stdout := NewRingBuffer(capacity)
stderr := NewRingBuffer(capacity)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
errOut, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
now := time.Now()
s := &Session{
ID: id, Command: command, Owner: owner, StartedAt: now, LastAccess: now, ExpiresAt: now.Add(DefaultSessionTTL), cmd: cmd,
stdin: stdin, stdout: stdout, stderr: stderr, done: make(chan struct{}), exitCode: -1,
}
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return nil, err
}
go s.copy(out, stdout)
go s.copy(errOut, stderr)
go s.wait()
go func() {
select {
case <-ctx.Done():
s.Terminate()
case <-s.done:
}
}()
return s, nil
}
func (s *Session) copy(src io.Reader, dst io.Writer) {
_, _ = io.Copy(dst, src)
}
func (s *Session) wait() {
err := s.cmd.Wait()
s.mu.Lock()
s.err = err
if err == nil {
s.exitCode = 0
} else if ee, ok := err.(*exec.ExitError); ok {
s.exitCode = ee.ExitCode()
} else {
s.exitCode = 1
}
s.mu.Unlock()
close(s.done)
}
func (s *Session) Done() <-chan struct{} { return s.done }
func (s *Session) Status() (int, bool, error) {
select {
case <-s.done:
s.mu.RLock()
defer s.mu.RUnlock()
return s.exitCode, true, s.err
default:
return -1, false, nil
}
}
func (s *Session) Output() (stdout, stderr string) {
stdout, stderr = string(s.stdout.Snapshot()), string(s.stderr.Snapshot())
s.mu.Lock()
if time.Now().Before(s.ExpiresAt) {
s.LastAccess = time.Now()
}
s.mu.Unlock()
return stdout, stderr
}
func (s *Session) Stdin(data []byte) error {
if len(data) > MaxSessionInput {
return errors.New("session input too large")
}
s.mu.RLock()
stdin := s.stdin
s.mu.RUnlock()
if stdin == nil {
return errors.New("session stdin unavailable")
}
_, err := stdin.Write(data)
if err == nil {
s.mu.Lock()
s.LastAccess = time.Now()
s.mu.Unlock()
}
return err
}
func (s *Session) Authorized(owner string) bool {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if owner == "" || s.Owner != owner || now.After(s.ExpiresAt) || now.Sub(s.LastAccess) > SessionIdleTTL {
return false
}
s.LastAccess = now
return true
}
func (s *Session) Expired() bool {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now()
return now.After(s.ExpiresAt) || now.Sub(s.LastAccess) > SessionIdleTTL
}
func (s *Session) Terminate() error {
s.mu.RLock()
pid := 0
if s.cmd != nil && s.cmd.Process != nil {
pid = s.cmd.Process.Pid
}
s.mu.RUnlock()
if pid == 0 {
return nil
}
return syscall.Kill(-pid, syscall.SIGTERM)
}
func (s *Session) Kill() error {
s.mu.RLock()
pid := 0
if s.cmd != nil && s.cmd.Process != nil {
pid = s.cmd.Process.Pid
}
s.mu.RUnlock()
if pid == 0 {
return nil
}
return syscall.Kill(-pid, syscall.SIGKILL)
}
// Compile-time sanity for the output writer contract.
var _ io.Writer = (*RingBuffer)(nil)
+104
View File
@@ -0,0 +1,104 @@
package executor
import (
"context"
"errors"
"sync"
"time"
)
const MaxSessionsPerManager = MaxSessionBufferTotal / DefaultRingCapacity
type SessionInfo struct {
ID string `json:"id"`
Command string `json:"command"`
StartedAt time.Time `json:"started_at"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
}
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*Session
limit int
}
func NewSessionManager(limit int) *SessionManager {
if limit <= 0 || limit > MaxSessionsPerManager {
limit = MaxSessionsPerManager
}
return &SessionManager{sessions: make(map[string]*Session), limit: limit}
}
func (m *SessionManager) Start(ctx context.Context, id, command, workdir, owner string, capacity int) (*Session, error) {
m.mu.Lock()
if _, ok := m.sessions[id]; ok {
m.mu.Unlock()
return nil, errors.New("session already exists")
}
if len(m.sessions) >= m.limit {
m.mu.Unlock()
return nil, errors.New("session limit reached")
}
s, err := StartSession(ctx, id, command, workdir, owner, capacity)
if err != nil {
m.mu.Unlock()
return nil, err
}
if _, exists := m.sessions[id]; exists {
m.mu.Unlock()
_ = s.Kill()
return nil, errors.New("session already exists")
}
m.sessions[id] = s
m.mu.Unlock()
return s, nil
}
func (m *SessionManager) Get(id string) (*Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
return s, ok
}
func (m *SessionManager) Remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.sessions[id]; !ok {
return false
}
delete(m.sessions, id)
return true
}
func (m *SessionManager) List() []SessionInfo {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]SessionInfo, 0, len(m.sessions))
for _, s := range m.sessions {
code, done, _ := s.Status()
out = append(out, SessionInfo{ID: s.ID, Command: s.Command, StartedAt: s.StartedAt, Running: !done, ExitCode: code})
}
return out
}
func (m *SessionManager) Stop(id string, force bool) error {
s, ok := m.Get(id)
if !ok {
return errors.New("session not found")
}
if force {
return s.Kill()
}
return s.Terminate()
}
func (m *SessionManager) Cleanup() {
m.mu.Lock()
defer m.mu.Unlock()
for id, s := range m.sessions {
if s.Expired() {
_, done, _ := s.Status()
if !done {
_ = s.Kill()
continue
}
delete(m.sessions, id)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package executor
import (
"context"
"strings"
"testing"
"time"
)
func TestRingBufferKeepsNewestData(t *testing.T) {
r := NewRingBuffer(8)
_, _ = r.Write([]byte("12345678"))
_, _ = r.Write([]byte("90"))
if got := string(r.Snapshot()); got != "34567890" {
t.Fatalf("got %q", got)
}
}
func TestSessionCapturesAndTerminates(t *testing.T) {
s, err := StartSession(context.Background(), "t1", "printf hello; sleep 5", "", "owner", 1024)
if err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
out, _ := s.Output()
if !strings.Contains(out, "hello") {
t.Fatalf("out=%q", out)
}
if err := s.Terminate(); err != nil {
t.Fatal(err)
}
select {
case <-s.Done():
case <-time.After(2 * time.Second):
t.Fatal("session did not terminate")
}
}
func TestSessionManagerDuplicateAndLimit(t *testing.T) {
m := NewSessionManager(1)
s, err := m.Start(context.Background(), "a", "sleep 1", "", "owner", 128)
if err != nil {
t.Fatal(err)
}
defer s.Kill()
if _, err := m.Start(context.Background(), "b", "true", "", "owner", 128); err == nil {
t.Fatal("expected limit")
}
if _, err := m.Start(context.Background(), "a", "true", "", "owner", 128); err == nil {
t.Fatal("expected duplicate")
}
}
func TestSessionIdleAuthorizationTouch(t *testing.T) {
s, err := StartSession(context.Background(), "idle-test", "sleep 2", "", "owner", 128)
if err != nil {
t.Fatal(err)
}
defer s.Kill()
s.mu.Lock()
s.LastAccess = time.Now().Add(-(SessionIdleTTL + time.Second))
s.mu.Unlock()
if s.Authorized("owner") {
t.Fatal("idle-expired session must not authorize")
}
s.mu.Lock()
s.LastAccess = time.Now()
s.mu.Unlock()
if !s.Authorized("owner") {
t.Fatal("active session should authorize")
}
}
+98
View File
@@ -0,0 +1,98 @@
package security
import (
"errors"
"sync"
"time"
)
const (
RiskLow = 1
RiskMedium = 2
RiskHigh = 3
)
type DevicePolicy struct {
Mode string
}
type PolicySnapshot struct {
Version uint64
GlobalRisk int
Devices map[string]DevicePolicy
CreatedAt time.Time
}
type Snapshot struct {
mu sync.RWMutex
data PolicySnapshot
}
func New(globalRisk int, devices map[string]DevicePolicy, version uint64) *Snapshot {
if globalRisk < RiskLow || globalRisk > RiskHigh {
globalRisk = RiskMedium
}
cp := make(map[string]DevicePolicy, len(devices))
for k, v := range devices {
cp[k] = v
}
return &Snapshot{data: PolicySnapshot{
Version: version, GlobalRisk: globalRisk, Devices: cp, CreatedAt: time.Now(),
}}
}
func (s *Snapshot) Current() PolicySnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
cp := make(map[string]DevicePolicy, len(s.data.Devices))
for k, v := range s.data.Devices {
cp[k] = v
}
snap := s.data
snap.Devices = cp
return snap
}
func (s *Snapshot) Publish(globalRisk int, devices map[string]DevicePolicy, version uint64) error {
if globalRisk < RiskLow || globalRisk > RiskHigh {
return errors.New("invalid global risk")
}
cp := make(map[string]DevicePolicy, len(devices))
for k, v := range devices {
cp[k] = v
}
s.mu.Lock()
if version < s.data.Version {
s.mu.Unlock()
return errors.New("policy version regression")
}
s.data = PolicySnapshot{
Version: version, GlobalRisk: globalRisk, Devices: cp, CreatedAt: time.Now(),
}
s.mu.Unlock()
return nil
}
func (s *Snapshot) EffectiveRisk(device string) int {
s.mu.RLock()
defer s.mu.RUnlock()
if p, ok := s.data.Devices[device]; ok {
if risk := ModeRisk(p.Mode); risk != 0 {
return risk
}
}
return s.data.GlobalRisk
}
func ModeRisk(mode string) int {
switch mode {
case "low":
return RiskLow
case "medium":
return RiskMedium
case "high":
return RiskHigh
default:
return 0
}
}
+35
View File
@@ -0,0 +1,35 @@
package security
import "testing"
func TestSnapshotEffectiveRiskAndPublish(t *testing.T) {
s := New(RiskMedium, map[string]DevicePolicy{"hwsg": {Mode: "high"}}, 7)
if got := s.EffectiveRisk("hwsg"); got != RiskHigh {
t.Fatalf("hwsg risk=%d, want %d", got, RiskHigh)
}
if got := s.EffectiveRisk("n3450"); got != RiskMedium {
t.Fatalf("default risk=%d, want %d", got, RiskMedium)
}
if err := s.Publish(RiskLow, map[string]DevicePolicy{"n3450": {Mode: "medium"}}, 8); err != nil {
t.Fatal(err)
}
cur := s.Current()
if cur.Version != 8 {
t.Fatalf("version=%d, want 8", cur.Version)
}
if got := s.EffectiveRisk("hwsg"); got != RiskLow {
t.Fatalf("old node risk=%d, want %d", got, RiskLow)
}
if got := s.EffectiveRisk("n3450"); got != RiskMedium {
t.Fatalf("override risk=%d, want %d", got, RiskMedium)
}
}
func TestModeRiskRejectsUnknown(t *testing.T) {
if got := ModeRisk("inherit"); got != 0 {
t.Fatalf("inherit risk=%d, want 0", got)
}
if got := ModeRisk("root"); got != 0 {
t.Fatalf("unknown risk=%d, want 0", got)
}
}
+52
View File
@@ -0,0 +1,52 @@
package security
type ToolSpec struct {
Name string
RequiredRisk int
Category string
ReadOnly bool
}
var registry = map[string]ToolSpec{
"read_file": {Name: "read_file", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"read_multiple_files": {Name: "read_multiple_files", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"list_directory": {Name: "list_directory", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"get_file_info": {Name: "get_file_info", RequiredRisk: RiskLow, Category: "file", ReadOnly: true},
"start_search": {Name: "start_search", RequiredRisk: RiskLow, Category: "search", ReadOnly: true},
"get_more_search_results": {Name: "get_more_search_results", RequiredRisk: RiskLow, Category: "search", ReadOnly: true},
"list_processes": {Name: "list_processes", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"list_sessions": {Name: "list_sessions", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"read_process_output": {Name: "read_process_output", RequiredRisk: RiskLow, Category: "process", ReadOnly: true},
"edit_block": {Name: "edit_block", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"write_file": {Name: "write_file", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"create_directory": {Name: "create_directory", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"move_file": {Name: "move_file", RequiredRisk: RiskMedium, Category: "file", ReadOnly: false},
"exec_command": {Name: "exec_command", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"start_process": {Name: "start_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"interact_with_process": {Name: "interact_with_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"kill_process": {Name: "kill_process", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"force_terminate": {Name: "force_terminate", RequiredRisk: RiskHigh, Category: "process", ReadOnly: false},
"mcp_call": {Name: "mcp_call", RequiredRisk: RiskMedium, Category: "mcp", ReadOnly: false},
}
var actionTools = map[string]string{
"read": "read_file", "probe": "read_file", "write": "write_file",
"mcp": "mcp_call", "exec": "exec_command",
}
func Lookup(name string) (ToolSpec, bool) {
v, ok := registry[name]
return v, ok
}
func RequiredRiskForAction(action string) (int, bool) {
name, ok := actionTools[action]
if !ok {
return 0, false
}
spec, ok := registry[name]
if !ok {
return 0, false
}
return spec.RequiredRisk, true
}
+35
View File
@@ -58,3 +58,38 @@ func TestAuditConcurrent100NoLoss(t *testing.T) {
t.Fatalf("busy_timeout=%d, want >=5000", busy)
}
}
func TestDeviceCredentialHashAndRotation(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "commander.db"))
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
if err := s.SetDeviceCredential("node-1", "secret-one"); err != nil {
t.Fatal(err)
}
configured, err := s.HasDeviceCredential("node-1")
if err != nil || !configured {
t.Fatalf("credential should be configured: %v", err)
}
valid, err := s.VerifyDeviceCredential("node-1", "secret-one")
if err != nil || !valid {
t.Fatalf("valid credential rejected: %v", err)
}
valid, err = s.VerifyDeviceCredential("node-1", "wrong")
if err != nil || valid {
t.Fatal("wrong credential accepted")
}
if err := s.SetDeviceCredential("node-1", "secret-two"); err != nil {
t.Fatal(err)
}
valid, err = s.VerifyDeviceCredential("node-1", "secret-one")
if err != nil || valid {
t.Fatal("old credential accepted after rotation")
}
valid, err = s.VerifyDeviceCredential("node-1", "secret-two")
if err != nil || !valid {
t.Fatal("rotated credential rejected")
}
}