chore: include complete dc refactor implementation and audits
Release / Build & Release (push) Canceled after 0s
Release / Build & Release (push) Canceled after 0s
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user