Phase3
This commit is contained in:
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/ws"
|
||||
)
|
||||
|
||||
const Version = "0.2.0-phase2"
|
||||
const Version = "0.3.0-phase3"
|
||||
|
||||
// Agent coordinates the node agent lifecycle until shutdown.
|
||||
type Agent struct {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
@@ -189,3 +193,134 @@ func mapSoftwareFamily(family string) string {
|
||||
return "VANILLA"
|
||||
}
|
||||
}
|
||||
|
||||
// StreamLogs streams container logs and invokes callback for each line.
|
||||
func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error {
|
||||
opts := container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: follow,
|
||||
Timestamps: false,
|
||||
}
|
||||
if tail > 0 {
|
||||
opts.Tail = strconv.Itoa(tail)
|
||||
} else {
|
||||
opts.Tail = "all"
|
||||
}
|
||||
|
||||
reader, err := c.cli.ContainerLogs(ctx, containerID, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("container logs: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
return readMultiplexedLogLines(ctx, reader, callback)
|
||||
}
|
||||
|
||||
// ExecCommand runs a command inside a container and returns stdout/stderr.
|
||||
func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) {
|
||||
execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("exec create: %w", err)
|
||||
}
|
||||
|
||||
attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("exec attach: %w", err)
|
||||
}
|
||||
defer attachResp.Close()
|
||||
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil {
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err)
|
||||
}
|
||||
|
||||
inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID)
|
||||
if err != nil {
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err)
|
||||
}
|
||||
if inspect.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(errBuf.String())
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(outBuf.String())
|
||||
}
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("exit code %d", inspect.ExitCode)
|
||||
}
|
||||
return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg)
|
||||
}
|
||||
|
||||
return outBuf.String(), errBuf.String(), nil
|
||||
}
|
||||
|
||||
// ExecRcon runs a Minecraft console command via rcon-cli inside the container.
|
||||
func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) {
|
||||
return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command})
|
||||
}
|
||||
|
||||
func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error {
|
||||
header := make([]byte, 8)
|
||||
var partial []byte
|
||||
var partialStream string
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := io.ReadFull(r, header)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stream := "stdout"
|
||||
if header[0] == 2 {
|
||||
stream = "stderr"
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(header[4:8])
|
||||
payload := make([]byte, size)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if partialStream != "" && partialStream != stream {
|
||||
if len(partial) > 0 {
|
||||
callback(partialStream, string(partial))
|
||||
partial = nil
|
||||
}
|
||||
}
|
||||
partialStream = stream
|
||||
partial = append(partial, payload...)
|
||||
|
||||
for {
|
||||
idx := bytes.IndexByte(partial, '\n')
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
line := string(partial[:idx])
|
||||
partial = partial[idx+1:]
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if line != "" {
|
||||
callback(stream, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(partial) > 0 {
|
||||
stream := partialStream
|
||||
if stream == "" {
|
||||
stream = "stdout"
|
||||
}
|
||||
callback(stream, string(partial))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
280
apps/node-agent/internal/files/safe.go
Normal file
280
apps/node-agent/internal/files/safe.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultMaxReadBytes = 1 << 20 // 1MB
|
||||
|
||||
var (
|
||||
ErrPathTraversal = errors.New("path escapes data root")
|
||||
ErrSymlink = errors.New("symlinks are not allowed")
|
||||
ErrNotFound = errors.New("file not found")
|
||||
ErrNotDirectory = errors.New("path is not a directory")
|
||||
ErrIsDirectory = errors.New("path is a directory")
|
||||
)
|
||||
|
||||
// DirEntry describes a file or directory entry returned by ListDir.
|
||||
type DirEntry struct {
|
||||
Name string
|
||||
Path string
|
||||
IsDir bool
|
||||
Size int64
|
||||
ModifiedAt time.Time
|
||||
}
|
||||
|
||||
// ResolvePath resolves relativePath under dataRoot and rejects path traversal.
|
||||
func ResolvePath(dataRoot, relativePath string) (string, error) {
|
||||
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(relativePath, "/")
|
||||
rel = strings.TrimPrefix(rel, `\`)
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
resolved := filepath.Join(root, rel)
|
||||
resolved, err = filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve path: %w", err)
|
||||
}
|
||||
|
||||
if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// ListDir lists entries in a directory relative to dataRoot.
|
||||
func ListDir(dataRoot, relativePath string) ([]DirEntry, error) {
|
||||
dirPath, err := ResolvePath(dataRoot, relativePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectSymlinks(dataRoot, dirPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Lstat(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, ErrNotDirectory
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]DirEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := rejectSymlinks(root, entryPath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entryInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, entryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
result = append(result, DirEntry{
|
||||
Name: entry.Name(),
|
||||
Path: rel,
|
||||
IsDir: entry.IsDir(),
|
||||
Size: entryInfo.Size(),
|
||||
ModifiedAt: entryInfo.ModTime().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].IsDir != result[j].IsDir {
|
||||
return result[i].IsDir
|
||||
}
|
||||
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadFile reads file content relative to dataRoot up to maxBytes.
|
||||
func ReadFile(dataRoot, relativePath string, maxBytes int) (content string, truncated bool, err error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = DefaultMaxReadBytes
|
||||
}
|
||||
|
||||
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
info, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, ErrNotFound
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", false, ErrIsDirectory
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", false, ErrSymlink
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
limited := io.LimitReader(f, int64(maxBytes)+1)
|
||||
data, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(data) > maxBytes {
|
||||
truncated = true
|
||||
data = data[:maxBytes]
|
||||
}
|
||||
|
||||
return string(data), truncated, nil
|
||||
}
|
||||
|
||||
// WriteFile atomically writes content to a path relative to dataRoot.
|
||||
func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
||||
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if !create {
|
||||
return ErrNotFound
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else if !create {
|
||||
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".hexahost-write-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
if _, err := tmp.WriteString(content); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, filePath); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectSymlinks(dataRoot, target string) error {
|
||||
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err = filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if target == root {
|
||||
info, err := os.Lstat(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: %s", ErrSymlink, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current := root
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
current = filepath.Join(current, part)
|
||||
info, err := os.Lstat(current)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: %s", ErrSymlink, current)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
94
apps/node-agent/internal/files/safe_test.go
Normal file
94
apps/node-agent/internal/files/safe_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePath_BlocksTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
tests := []string{
|
||||
"../outside",
|
||||
"../../etc/passwd",
|
||||
"foo/../../outside",
|
||||
}
|
||||
|
||||
for _, rel := range tests {
|
||||
t.Run(rel, func(t *testing.T) {
|
||||
_, err := ResolvePath(root, rel)
|
||||
if err == nil {
|
||||
t.Fatalf("expected path traversal error for %q", rel)
|
||||
}
|
||||
if err != ErrPathTraversal {
|
||||
t.Fatalf("expected ErrPathTraversal, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePath_AllowsNestedPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "world", "region")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolved, err := ResolvePath(root, "world/region")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve nested path: %v", err)
|
||||
}
|
||||
if resolved != nested {
|
||||
t.Fatalf("resolved %q want %q", resolved, nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile_BlocksTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(filepath.Dir(root), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(secret) })
|
||||
|
||||
_, _, err := ReadFile(root, "../"+filepath.Base(secret))
|
||||
if err == nil {
|
||||
t.Fatal("expected path traversal error")
|
||||
}
|
||||
if err != ErrPathTraversal {
|
||||
t.Fatalf("expected ErrPathTraversal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFile_AtomicWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
rel := "server.properties"
|
||||
|
||||
if err := WriteFile(root, rel, "motd=Hello", true); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
content, truncated, err := ReadFile(root, rel, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
if truncated {
|
||||
t.Fatal("did not expect truncated read")
|
||||
}
|
||||
if content != "motd=Hello" {
|
||||
t.Fatalf("content %q want %q", content, "motd=Hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDir_RejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
_, err := ListDir(root, "..")
|
||||
if err == nil {
|
||||
t.Fatal("expected path traversal error")
|
||||
}
|
||||
if err != ErrPathTraversal {
|
||||
t.Fatalf("expected ErrPathTraversal, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -137,12 +137,76 @@ type ServerStopPayload struct {
|
||||
OperationMeta
|
||||
}
|
||||
|
||||
// ServerCommandPayload instructs the agent to run a console command via RCON.
|
||||
type ServerCommandPayload struct {
|
||||
OperationMeta
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ServerLogsSubscribePayload instructs the agent to stream container logs.
|
||||
type ServerLogsSubscribePayload struct {
|
||||
OperationMeta
|
||||
TailLines int `json:"tailLines"`
|
||||
Follow bool `json:"follow"`
|
||||
}
|
||||
|
||||
// ServerLogPayload carries a single log line from a game server.
|
||||
type ServerLogPayload struct {
|
||||
OperationMeta
|
||||
Line string `json:"line"`
|
||||
Stream string `json:"stream"`
|
||||
}
|
||||
|
||||
// ServerFilesListPayload lists files in a server data directory.
|
||||
type ServerFilesListPayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ServerFilesReadPayload reads a file from the server data directory.
|
||||
type ServerFilesReadPayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
MaxBytes int `json:"maxBytes"`
|
||||
}
|
||||
|
||||
// ServerFilesWritePayload writes content to a file in the server data directory.
|
||||
type ServerFilesWritePayload struct {
|
||||
OperationMeta
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Create bool `json:"create"`
|
||||
}
|
||||
|
||||
// FileEntry describes a file or directory in a list response.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// ServerFilesListResultPayload is the result of a files.list operation.
|
||||
type ServerFilesListResultPayload struct {
|
||||
Entries []FileEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// ServerFilesReadResultPayload is the result of a files.read operation.
|
||||
type ServerFilesReadResultPayload struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
||||
type ServerOperationResultPayload struct {
|
||||
OperationMeta
|
||||
ResultCode string `json:"resultCode"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
ResultCode string `json:"resultCode"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// ServerStatePayload reports the observed server state.
|
||||
|
||||
@@ -212,3 +212,168 @@ func TestNewEnvelope(t *testing.T) {
|
||||
t.Fatalf("protocol version %d", env.ProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase3Payloads_JSONRoundtrip(t *testing.T) {
|
||||
meta := OperationMeta{
|
||||
ServerID: "srv-01",
|
||||
Generation: 3,
|
||||
}
|
||||
|
||||
command := ServerCommandPayload{
|
||||
OperationMeta: meta,
|
||||
Command: "say hello",
|
||||
}
|
||||
commandRaw, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decodedCommand ServerCommandPayload
|
||||
if err := json.Unmarshal(commandRaw, &decodedCommand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decodedCommand.Command != command.Command {
|
||||
t.Fatalf("command: got %q want %q", decodedCommand.Command, command.Command)
|
||||
}
|
||||
|
||||
logsSub := ServerLogsSubscribePayload{
|
||||
OperationMeta: meta,
|
||||
TailLines: 100,
|
||||
Follow: true,
|
||||
}
|
||||
logsSubRaw, err := json.Marshal(logsSub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decodedLogsSub ServerLogsSubscribePayload
|
||||
if err := json.Unmarshal(logsSubRaw, &decodedLogsSub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decodedLogsSub.TailLines != 100 || !decodedLogsSub.Follow {
|
||||
t.Fatalf("logs subscribe mismatch: %+v", decodedLogsSub)
|
||||
}
|
||||
|
||||
filesList := ServerFilesListPayload{
|
||||
OperationMeta: meta,
|
||||
Path: "world",
|
||||
}
|
||||
filesListRaw, err := json.Marshal(filesList)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-list",
|
||||
Type: TypeServerFilesList,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesListRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files list envelope: %v", err)
|
||||
}
|
||||
|
||||
filesRead := ServerFilesReadPayload{
|
||||
OperationMeta: meta,
|
||||
Path: "server.properties",
|
||||
MaxBytes: 4096,
|
||||
}
|
||||
filesReadRaw, err := json.Marshal(filesRead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-read",
|
||||
Type: TypeServerFilesRead,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesReadRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files read envelope: %v", err)
|
||||
}
|
||||
|
||||
filesWrite := ServerFilesWritePayload{
|
||||
OperationMeta: meta,
|
||||
Path: "ops.txt",
|
||||
Content: "test",
|
||||
Create: true,
|
||||
}
|
||||
filesWriteRaw, err := json.Marshal(filesWrite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-files-write",
|
||||
Type: TypeServerFilesWrite,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: filesWriteRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("files write envelope: %v", err)
|
||||
}
|
||||
|
||||
logLine := ServerLogPayload{
|
||||
OperationMeta: meta,
|
||||
Line: "[Server] Done",
|
||||
Stream: "stdout",
|
||||
}
|
||||
logLineRaw, err := json.Marshal(logLine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env = &Envelope{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
MessageID: "msg-log",
|
||||
Type: TypeServerLog,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Payload: logLineRaw,
|
||||
}
|
||||
if err := env.Validate(); err != nil {
|
||||
t.Fatalf("server log envelope: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerOperationResultPayload_WithResult(t *testing.T) {
|
||||
listResult, err := json.Marshal(ServerFilesListResultPayload{
|
||||
Entries: []FileEntry{{
|
||||
Name: "server.properties",
|
||||
Path: "server.properties",
|
||||
IsDir: false,
|
||||
Size: 128,
|
||||
ModifiedAt: "2026-06-26T12:00:00Z",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload := ServerOperationResultPayload{
|
||||
OperationMeta: OperationMeta{ServerID: "srv-01", Generation: 1},
|
||||
ResultCode: "ok",
|
||||
Operation: "files.list",
|
||||
Result: listResult,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded ServerOperationResultPayload
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Operation != "files.list" {
|
||||
t.Fatalf("operation: got %q", decoded.Operation)
|
||||
}
|
||||
if len(decoded.Result) == 0 {
|
||||
t.Fatal("expected result payload")
|
||||
}
|
||||
|
||||
var result ServerFilesListResultPayload
|
||||
if err := json.Unmarshal(decoded.Result, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Entries) != 1 || result.Entries[0].Name != "server.properties" {
|
||||
t.Fatalf("unexpected list result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/files"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -27,6 +29,7 @@ const (
|
||||
// Responder sends protocol responses back to the control plane.
|
||||
type Responder interface {
|
||||
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
||||
SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error
|
||||
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
}
|
||||
@@ -42,20 +45,22 @@ type serverRecord struct {
|
||||
|
||||
// Manager tracks provisioned servers and executes lifecycle operations.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
docker *docker.Client
|
||||
servers map[string]*serverRecord
|
||||
resp Responder
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
docker *docker.Client
|
||||
servers map[string]*serverRecord
|
||||
logStreams map[string]context.CancelFunc
|
||||
resp Responder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a runtime manager backed by the given Docker client.
|
||||
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
|
||||
return &Manager{
|
||||
docker: dockerClient,
|
||||
servers: make(map[string]*serverRecord),
|
||||
resp: resp,
|
||||
log: log,
|
||||
docker: dockerClient,
|
||||
servers: make(map[string]*serverRecord),
|
||||
logStreams: make(map[string]context.CancelFunc),
|
||||
resp: resp,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +189,8 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
||||
|
||||
m.stopLogStream(meta.ServerID)
|
||||
|
||||
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
||||
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
||||
@@ -204,6 +211,147 @@ func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta p
|
||||
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||
}
|
||||
|
||||
// HandleLogsSubscribe starts streaming container logs for a server.
|
||||
func (m *Manager) HandleLogsSubscribe(ctx context.Context, correlationID string, payload protocol.ServerLogsSubscribePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.logStreams[meta.ServerID]; ok {
|
||||
existing()
|
||||
}
|
||||
m.logStreams[meta.ServerID] = cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
if current, ok := m.logStreams[meta.ServerID]; ok && current == cancel {
|
||||
delete(m.logStreams, meta.ServerID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
err := m.docker.StreamLogs(streamCtx, rec.ContainerID, payload.TailLines, payload.Follow, func(stream, line string) {
|
||||
logPayload := protocol.ServerLogPayload{
|
||||
OperationMeta: meta,
|
||||
Line: line,
|
||||
Stream: stream,
|
||||
}
|
||||
if err := m.resp.SendLog(ctx, correlationID, logPayload); err != nil {
|
||||
m.log.Warn("send server.log failed",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
})
|
||||
if err != nil && streamCtx.Err() == nil {
|
||||
m.log.Warn("log stream ended with error",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// HandleCommand executes a console command via RCON.
|
||||
func (m *Manager) HandleCommand(ctx context.Context, correlationID string, payload protocol.ServerCommandPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "command", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
stdout, stderr, err := m.docker.ExecRcon(ctx, rec.ContainerID, payload.Command)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "command", "COMMAND_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "command", map[string]string{
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesList lists files in the server data directory.
|
||||
func (m *Manager) HandleFilesList(ctx context.Context, correlationID string, payload protocol.ServerFilesListPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.list", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := files.ListDir(rec.DataPath, payload.Path)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.list", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result := protocol.ServerFilesListResultPayload{
|
||||
Entries: make([]protocol.FileEntry, 0, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
result.Entries = append(result.Entries, protocol.FileEntry{
|
||||
Name: entry.Name,
|
||||
Path: entry.Path,
|
||||
IsDir: entry.IsDir,
|
||||
Size: entry.Size,
|
||||
ModifiedAt: entry.ModifiedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.list", result)
|
||||
}
|
||||
|
||||
// HandleFilesRead reads a file from the server data directory.
|
||||
func (m *Manager) HandleFilesRead(ctx context.Context, correlationID string, payload protocol.ServerFilesReadPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.read", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
content, truncated, err := files.ReadFile(rec.DataPath, payload.Path, payload.MaxBytes)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.read", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.read", protocol.ServerFilesReadResultPayload{
|
||||
Path: payload.Path,
|
||||
Content: content,
|
||||
Truncated: truncated,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleFilesWrite writes a file in the server data directory.
|
||||
func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, payload protocol.ServerFilesWritePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "files.write", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if err := files.WriteFile(rec.DataPath, payload.Path, payload.Content, payload.Create); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "files.write", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "files.write", map[string]string{
|
||||
"path": payload.Path,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -223,6 +371,15 @@ func (m *Manager) setState(serverID, state string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) stopLogStream(serverID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if cancel, ok := m.logStreams[serverID]; ok {
|
||||
cancel()
|
||||
delete(m.logStreams, serverID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
||||
payload := protocol.ServerStatePayload{
|
||||
OperationMeta: meta,
|
||||
@@ -267,3 +424,59 @@ func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) completeOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation string, result any) {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, operation, "RESULT_MARSHAL_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeOK,
|
||||
Operation: operation,
|
||||
Result: raw,
|
||||
}
|
||||
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation complete failed",
|
||||
"server_id", meta.ServerID,
|
||||
"operation", operation,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) failOperation(ctx context.Context, correlationID string, meta protocol.OperationMeta, operation, errorCode, errorMessage string) {
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeError,
|
||||
Operation: operation,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation failed failed",
|
||||
"server_id", meta.ServerID,
|
||||
"operation", operation,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func filesErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, files.ErrPathTraversal):
|
||||
return "PATH_TRAVERSAL"
|
||||
case errors.Is(err, files.ErrSymlink):
|
||||
return "SYMLINK_NOT_ALLOWED"
|
||||
case errors.Is(err, files.ErrNotFound):
|
||||
return "FILE_NOT_FOUND"
|
||||
case errors.Is(err, files.ErrNotDirectory):
|
||||
return "NOT_A_DIRECTORY"
|
||||
case errors.Is(err, files.ErrIsDirectory):
|
||||
return "IS_DIRECTORY"
|
||||
default:
|
||||
return "FILES_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,46 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
}
|
||||
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||
|
||||
case protocol.TypeServerLogsSubscribe:
|
||||
var payload protocol.ServerLogsSubscribePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.logs.subscribe", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleLogsSubscribe(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerCommand:
|
||||
var payload protocol.ServerCommandPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.command", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleCommand(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesList:
|
||||
var payload protocol.ServerFilesListPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.list", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesList(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesRead:
|
||||
var payload protocol.ServerFilesReadPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.read", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesRead(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerFilesWrite:
|
||||
var payload protocol.ServerFilesWritePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.files.write", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
@@ -333,6 +373,11 @@ func (c *Client) SendState(ctx context.Context, correlationID string, payload pr
|
||||
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendLog implements runtime.Responder.
|
||||
func (c *Client) SendLog(ctx context.Context, correlationID string, payload protocol.ServerLogPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerLog, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationComplete implements runtime.Responder.
|
||||
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||
|
||||
Reference in New Issue
Block a user