mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 16:10:59 +00:00
843b550651
Runtime package management with security hardening: - pkg-helper: root-privileged daemon for apk install/uninstall via Unix socket - HTTP API: /v1/packages (list/install/uninstall/runtimes), admin role required for writes - Shell deny groups: 15 configurable groups (per-agent overrides via context) - Packages UI: Web page for managing system/pip/npm packages with confirmation dialogs - Docker: privilege separation (root entrypoint → su-exec drop), init for zombie reaping - Security: umask socket creation, persist file validation, deny pattern hardening (Node.js fetch/http, Python from/import, curl localhost, sensitive env vars) - Auth: empty gateway token → admin role (dev/single-user mode)
226 lines
5.6 KiB
Go
226 lines
5.6 KiB
Go
// pkg-helper is a root-privileged helper that listens on a Unix socket
|
|
// and executes apk add/del commands on behalf of the non-root app process.
|
|
// It is started by docker-entrypoint.sh before dropping privileges.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"regexp"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const socketPath = "/tmp/pkg.sock"
|
|
|
|
// validPkgName allows alphanumeric, hyphens, underscores, dots, @, / (scoped npm).
|
|
// Rejects names starting with - to prevent argument injection.
|
|
var validPkgName = regexp.MustCompile(`^[a-zA-Z0-9@][a-zA-Z0-9._+\-/@]*$`)
|
|
|
|
type request struct {
|
|
Action string `json:"action"`
|
|
Package string `json:"package"`
|
|
}
|
|
|
|
type response struct {
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
slog.Info("pkg-helper: starting", "socket", socketPath)
|
|
|
|
// Remove stale socket.
|
|
os.Remove(socketPath)
|
|
|
|
// Restrictive umask: socket created as 0660 (not default 0777).
|
|
oldMask := syscall.Umask(0117)
|
|
listener, err := net.Listen("unix", socketPath)
|
|
syscall.Umask(oldMask)
|
|
if err != nil {
|
|
slog.Error("pkg-helper: listen failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer listener.Close()
|
|
|
|
// Socket permissions: owner root, group goclaw (gid 1000), mode 0660.
|
|
// Chown requires CAP_CHOWN; if missing (misconfigured container), warn but continue
|
|
// since umask already set restrictive permissions.
|
|
if os.Getuid() == 0 {
|
|
if err := os.Chown(socketPath, 0, 1000); err != nil {
|
|
slog.Warn("pkg-helper: chown socket failed (missing CAP_CHOWN?)", "error", err)
|
|
}
|
|
}
|
|
if err := os.Chmod(socketPath, 0660); err != nil {
|
|
slog.Warn("pkg-helper: chmod socket failed", "error", err)
|
|
}
|
|
|
|
// Graceful shutdown on SIGTERM/SIGINT.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
|
go func() {
|
|
<-sigCh
|
|
slog.Info("pkg-helper: shutting down")
|
|
listener.Close()
|
|
os.Remove(socketPath)
|
|
os.Exit(0)
|
|
}()
|
|
|
|
const maxConns = 3
|
|
sem := make(chan struct{}, maxConns)
|
|
|
|
slog.Info("pkg-helper: ready")
|
|
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
break
|
|
}
|
|
select {
|
|
case sem <- struct{}{}:
|
|
go func(c net.Conn) {
|
|
defer func() { <-sem }()
|
|
c.SetDeadline(time.Now().Add(30 * time.Second)) //nolint:errcheck
|
|
handleConn(c)
|
|
}(conn)
|
|
default:
|
|
slog.Warn("pkg-helper: connection limit reached, rejecting")
|
|
conn.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
|
|
scanner := bufio.NewScanner(conn)
|
|
encoder := json.NewEncoder(conn)
|
|
|
|
for scanner.Scan() {
|
|
var req request
|
|
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
|
|
encoder.Encode(response{Error: "invalid json"}) //nolint:errcheck
|
|
continue
|
|
}
|
|
|
|
resp := handleRequest(req)
|
|
encoder.Encode(resp) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func handleRequest(req request) response {
|
|
pkg := req.Package
|
|
if pkg == "" {
|
|
return response{Error: "package required"}
|
|
}
|
|
if !validPkgName.MatchString(pkg) {
|
|
return response{Error: "invalid package name"}
|
|
}
|
|
|
|
switch req.Action {
|
|
case "install":
|
|
return doInstall(pkg)
|
|
case "uninstall":
|
|
return doUninstall(pkg)
|
|
default:
|
|
return response{Error: fmt.Sprintf("unknown action: %s", req.Action)}
|
|
}
|
|
}
|
|
|
|
func doInstall(pkg string) response {
|
|
slog.Info("pkg-helper: installing", "package", pkg)
|
|
|
|
cmd := exec.Command("apk", "add", "--no-cache", pkg)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
|
|
slog.Error("pkg-helper: install failed", "package", pkg, "error", msg)
|
|
return response{Error: msg}
|
|
}
|
|
|
|
persistAdd(pkg)
|
|
slog.Info("pkg-helper: installed", "package", pkg)
|
|
return response{OK: true}
|
|
}
|
|
|
|
func doUninstall(pkg string) response {
|
|
slog.Info("pkg-helper: uninstalling", "package", pkg)
|
|
|
|
cmd := exec.Command("apk", "del", pkg)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
msg := fmt.Sprintf("%s: %v", strings.TrimSpace(string(out)), err)
|
|
slog.Error("pkg-helper: uninstall failed", "package", pkg, "error", msg)
|
|
return response{Error: msg}
|
|
}
|
|
|
|
persistRemove(pkg)
|
|
slog.Info("pkg-helper: uninstalled", "package", pkg)
|
|
return response{OK: true}
|
|
}
|
|
|
|
// persistAdd appends a package name to the apk persist file (dedup check).
|
|
func persistAdd(pkg string) {
|
|
listFile := apkListFile()
|
|
|
|
// Check if already persisted (avoid duplicates).
|
|
if data, err := os.ReadFile(listFile); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if strings.TrimSpace(line) == pkg {
|
|
return // already persisted
|
|
}
|
|
}
|
|
}
|
|
|
|
f, err := os.OpenFile(listFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640)
|
|
if err != nil {
|
|
slog.Warn("pkg-helper: persist add failed", "error", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
fmt.Fprintln(f, pkg)
|
|
}
|
|
|
|
// persistRemove removes a package name from the apk persist file.
|
|
// Uses write-to-temp-then-rename for atomic update (avoids truncation on disk-full).
|
|
func persistRemove(pkg string) {
|
|
listFile := apkListFile()
|
|
data, err := os.ReadFile(listFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var kept []string
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && line != pkg {
|
|
kept = append(kept, line)
|
|
}
|
|
}
|
|
|
|
tmpFile := listFile + ".tmp"
|
|
if err := os.WriteFile(tmpFile, []byte(strings.Join(kept, "\n")+"\n"), 0640); err != nil {
|
|
slog.Warn("pkg-helper: persist remove write failed", "error", err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmpFile, listFile); err != nil {
|
|
slog.Warn("pkg-helper: persist remove rename failed", "error", err)
|
|
os.Remove(tmpFile) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func apkListFile() string {
|
|
runtimeDir := os.Getenv("RUNTIME_DIR")
|
|
if runtimeDir == "" {
|
|
runtimeDir = "/app/data/.runtime"
|
|
}
|
|
return runtimeDir + "/apk-packages"
|
|
}
|