Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -95,6 +95,99 @@ func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, er
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
}
// CreateTarGzFromPaths archives one or more paths under dataRoot into outputPath.
func CreateTarGzFromPaths(dataRoot string, relativePaths []string, outputPath string) (sha256sum string, size int64, err error) {
dataRoot = filepath.Clean(dataRoot)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return "", 0, err
}
file, err := os.Create(outputPath)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
gzipWriter := gzip.NewWriter(writer)
tarWriter := tar.NewWriter(gzipWriter)
for _, rel := range relativePaths {
abs := filepath.Join(dataRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
if !strings.HasPrefix(abs, dataRoot+string(os.PathSeparator)) && abs != dataRoot {
return "", 0, fmt.Errorf("path escapes data root: %s", rel)
}
info, err := os.Stat(abs)
if err != nil {
return "", 0, err
}
baseName := filepath.Base(abs)
if info.IsDir() {
err = filepath.Walk(abs, func(path string, entry os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
innerRel, err := filepath.Rel(abs, path)
if err != nil {
return err
}
if innerRel == "." {
return nil
}
headerName := filepath.ToSlash(filepath.Join(baseName, innerRel))
return writeTarEntry(tarWriter, path, entry, headerName)
})
if err != nil {
return "", 0, err
}
continue
}
if err := writeTarEntry(tarWriter, abs, info, baseName); err != nil {
return "", 0, err
}
}
if err := tarWriter.Close(); err != nil {
return "", 0, err
}
if err := gzipWriter.Close(); err != nil {
return "", 0, err
}
stat, err := file.Stat()
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
}
func writeTarEntry(tarWriter *tar.Writer, path string, entry os.FileInfo, headerName string) error {
header, err := tar.FileInfoHeader(entry, "")
if err != nil {
return err
}
header.Name = headerName
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
return nil
}
sourceFile, err := os.Open(path)
if err != nil {
return err
}
defer sourceFile.Close()
_, err = io.Copy(tarWriter, sourceFile)
return err
}
// ExtractTarGz extracts archivePath into destDir.
func ExtractTarGz(archivePath, destDir string) error {
destDir = filepath.Clean(destDir)

View File

@@ -113,9 +113,19 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer
Target: "/data",
}},
Resources: container.Resources{
Memory: int64(spec.RamMb) * 1024 * 1024,
Memory: int64(spec.RamMb) * 1024 * 1024,
PidsLimit: ptrInt64(256),
},
Privileged: false,
CapDrop: []string{"ALL"},
SecurityOpt: []string{"no-new-privileges:true"},
LogConfig: container.LogConfig{
Type: "json-file",
Config: map[string]string{
"max-size": "10m",
"max-file": "3",
},
},
Privileged: false,
},
nil,
nil,
@@ -198,6 +208,10 @@ func mapSoftwareFamily(family string) string {
}
}
func ptrInt64(value int64) *int64 {
return &value
}
// 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{

View File

@@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
return nil
}
// RemovePath deletes a file or directory relative to dataRoot.
func RemovePath(dataRoot, relativePath string) error {
target, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if err := rejectSymlinks(dataRoot, target); err != nil {
return err
}
info, err := os.Lstat(target)
if err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
if info.IsDir() {
return os.RemoveAll(target)
}
return os.Remove(target)
}
// DeleteFile removes a file relative to dataRoot.
func DeleteFile(dataRoot, relativePath string) error {
filePath, err := ResolvePath(dataRoot, relativePath)

View File

@@ -38,6 +38,9 @@ const (
TypeServerFilesList MessageType = "server.files.list"
TypeServerFilesRead MessageType = "server.files.read"
TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesDelete MessageType = "server.files.delete"
TypeServerFilesArchive MessageType = "server.files.archive"
TypeServerFilesUnarchive MessageType = "server.files.unarchive"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerWorldArchive MessageType = "server.world.archive"
@@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerFilesList: {},
TypeServerFilesRead: {},
TypeServerFilesWrite: {},
TypeServerFilesDelete: {},
TypeServerFilesArchive: {},
TypeServerFilesUnarchive: {},
TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {},
TypeServerWorldArchive: {},
@@ -185,9 +191,30 @@ type ServerFilesReadPayload struct {
// 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"`
Path string `json:"path"`
Content string `json:"content"`
Create bool `json:"create"`
Encoding string `json:"encoding,omitempty"`
}
// ServerFilesDeletePayload deletes a file or directory relative to data root.
type ServerFilesDeletePayload struct {
OperationMeta
Path string `json:"path"`
}
// ServerFilesArchivePayload creates a tar.gz archive from paths.
type ServerFilesArchivePayload struct {
OperationMeta
Paths []string `json:"paths"`
ArchiveName string `json:"archiveName,omitempty"`
}
// ServerFilesUnarchivePayload extracts a tar.gz archive.
type ServerFilesUnarchivePayload struct {
OperationMeta
ArchivePath string `json:"archivePath"`
DestinationPath string `json:"destinationPath"`
}
// ServerWorldValidatePayload validates a Minecraft world directory.

View File

@@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hexahost/gamecloud/node-agent/internal/archive"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
@@ -357,6 +360,88 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
})
}
// HandleFilesDelete removes a file or directory from the server data directory.
func (m *Manager) HandleFilesDelete(ctx context.Context, correlationID string, payload protocol.ServerFilesDeletePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.delete", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if err := files.RemovePath(rec.DataPath, payload.Path); err != nil {
m.failOperation(ctx, correlationID, meta, "files.delete", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.delete", map[string]string{
"path": payload.Path,
})
}
// HandleFilesArchive creates a tar.gz archive from selected paths.
func (m *Manager) HandleFilesArchive(ctx context.Context, correlationID string, payload protocol.ServerFilesArchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
archiveName := payload.ArchiveName
if archiveName == "" {
archiveName = fmt.Sprintf("archive-%d.tar.gz", time.Now().Unix())
}
if !strings.HasSuffix(archiveName, ".tar.gz") {
archiveName += ".tar.gz"
}
outputPath := filepath.Join(rec.DataPath, ".hexahost-archives", archiveName)
checksum, size, err := archive.CreateTarGzFromPaths(rec.DataPath, payload.Paths, outputPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.archive", "ARCHIVE_FAILED", err.Error())
return
}
rel, _ := filepath.Rel(rec.DataPath, outputPath)
m.completeOperation(ctx, correlationID, meta, "files.archive", map[string]any{
"archivePath": filepath.ToSlash(rel),
"sha256": checksum,
"size": size,
})
}
// HandleFilesUnarchive extracts a tar.gz archive into a destination path.
func (m *Manager) HandleFilesUnarchive(ctx context.Context, correlationID string, payload protocol.ServerFilesUnarchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "files.unarchive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
archiveAbs, err := files.ResolvePath(rec.DataPath, payload.ArchivePath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
return
}
destAbs, err := files.ResolvePath(rec.DataPath, payload.DestinationPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error())
return
}
if err := archive.ExtractTarGz(archiveAbs, destAbs); err != nil {
m.failOperation(ctx, correlationID, meta, "files.unarchive", "UNARCHIVE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "files.unarchive", map[string]string{
"destinationPath": payload.DestinationPath,
})
}
// HandleBackupPrepare runs save-off and save-all flush via RCON.
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)

View File

@@ -314,6 +314,30 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
case protocol.TypeServerFilesDelete:
var payload protocol.ServerFilesDeletePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.delete", "error", err)
return
}
c.runtime.HandleFilesDelete(ctx, env.MessageID, payload)
case protocol.TypeServerFilesArchive:
var payload protocol.ServerFilesArchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.archive", "error", err)
return
}
c.runtime.HandleFilesArchive(ctx, env.MessageID, payload)
case protocol.TypeServerFilesUnarchive:
var payload protocol.ServerFilesUnarchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.files.unarchive", "error", err)
return
}
c.runtime.HandleFilesUnarchive(ctx, env.MessageID, payload)
case protocol.TypeServerBackupPrepare:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {