From 9abd3aebded9df58593cf602a9fd1608d065e0e2 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 02:23:50 +0100 Subject: [PATCH 1/3] docs: Add 0.3.0 changelog entry --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2dbe5..f4a96e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.3.0] - 2026-06-08 + +### Added +- AI assistant: interactive sessions that investigate before answering, grounded in this installation's context, with inline tool-call approval, suggested actions and any OpenAI-compatible provider configured at runtime +- Server-side plans: preview and apply mutations as reviewable artifacts, with opt-in enforcement per deployment +- Optional app template for image and compose deployments: the user's image or compose content is kept while the template contributes container port, default bind mounts, pre-created directories and ownership +- Template-defined environment file generation: prefers the env example shipped inside the deployed image, falls back to template content, generates per-deployment secrets (e.g. Laravel `APP_KEY`) and fills in database credentials +- Interactive system terminal on a real PTY over websocket, with per-line protected-command enforcement and the global terminal disable honored +- Persisted file manager preference for showing hidden files (default on), exposed through the key-based config API +- Template mounts can declare a host path; single-file mounts (such as `.env`) are kept as files +- Per-service image pulling + +### Changed +- Mount ownership is applied recursively so nested directories belong to the container user +- Built-in template copies on disk sync automatically when the agent build changes, so upgrades take effect without a manual refresh +- Laravel template: complete storage subdirectory tree, bootstrap cache mount, env file mounted as a file +- Prompts composed by the product (log analysis, operation diagnosis) are kept out of AI session transcripts while still reaching the model +- Nginx reloads when forwarded-proxy trust settings change + +### Fixed +- Forwarded client IPs are only trusted when sent by configured proxies, and trusted-proxy entries are sanitized before injection into the nginx Lua layer +- e2e suite aborts on leftover root-owned state instead of failing unrelated tests + ## [0.2.0] - 2026-05-25 ### Added From 2c6a12670ed7cbc11345f082e297256203a5017d Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 02:33:16 +0100 Subject: [PATCH 2/3] fix(security): Chown symlinks themselves during mount ownership The recursive ownership pass runs as root after every deployment start, over content the container wrote, and followed symlinks. A container could plant a link in its bind mount and have any host file chowned to the container user on the next start. Links are now changed themselves and their targets left untouched. Closes #143 --- internal/docker/discovery.go | 8 +++++--- internal/docker/discovery_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index dd72881..2e7a887 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -399,9 +399,9 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn } } - if info, err := os.Stat(base); err == nil && !info.IsDir() { + if info, err := os.Lstat(base); err == nil && !info.IsDir() { if m.User != "" { - if err := os.Chown(base, uid, gid); err != nil { + if err := os.Lchown(base, uid, gid); err != nil { return fmt.Errorf("chown %s: %w", base, err) } } @@ -426,7 +426,9 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn if walkErr != nil { return walkErr } - return os.Chown(path, uid, gid) + // Runs as root over container-written content: following a + // planted symlink would chown an arbitrary host file. + return os.Lchown(path, uid, gid) }) if err != nil { return fmt.Errorf("chown %s: %w", base, err) diff --git a/internal/docker/discovery_test.go b/internal/docker/discovery_test.go index 5433b3d..ba20e79 100644 --- a/internal/docker/discovery_test.go +++ b/internal/docker/discovery_test.go @@ -334,6 +334,35 @@ func TestApplyMountOwnership(t *testing.T) { } }) + t.Run("does not follow symlinks while chowning", func(t *testing.T) { + // Chowning through the link to this root-owned target would fail + // as non-root; success proves the link itself was changed. + target := "/etc/hostname" + if _, err := os.Stat(target); err != nil { + t.Skipf("no stable root-owned target available: %v", err) + } + + base := filepath.Join(deploymentPath, "linked") + if err := os.MkdirAll(base, 0755); err != nil { + t.Fatalf("Failed to create mount dir: %v", err) + } + if err := os.Symlink(target, filepath.Join(base, "escape")); err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + mounts := []MountOwnership{ + { + HostPath: "./linked", + User: user, + }, + } + + if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil { + t.Fatalf("ApplyMountOwnership failed on a mount containing a symlink: %v", err) + } + }) + t.Run("chowns recursively including pre-existing content", func(t *testing.T) { base := filepath.Join(deploymentPath, "data") nested := filepath.Join(base, "deep", "dir") From fdc03a41cedb0aae48b023dc73e7b4d74c676bc7 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 02:33:16 +0100 Subject: [PATCH 3/3] fix(security): Remove modulo bias from generated alphanumeric secrets Alphanumeric secret characters were drawn with a modulo over the random byte, slightly favoring the first characters of the set. Each character is now drawn uniformly. Closes #144 --- internal/api/server.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index c366033..e2f1f7b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "log" + "math/big" "net/http" "os" "os/exec" @@ -4115,23 +4116,31 @@ func generateSecretValue(spec TemplateEnvSecret) (string, error) { if n <= 0 { n = 32 } - buf := make([]byte, n) - if _, err := cryptoRand.Read(buf); err != nil { - return "", err - } var value string switch spec.Encoding { case "hex": + buf := make([]byte, n) + if _, err := cryptoRand.Read(buf); err != nil { + return "", err + } value = hex.EncodeToString(buf) case "alphanumeric": const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" out := make([]byte, n) - for i, b := range buf { - out[i] = charset[int(b)%len(charset)] + for i := range out { + idx, err := cryptoRand.Int(cryptoRand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + out[i] = charset[idx.Int64()] } value = string(out) default: + buf := make([]byte, n) + if _, err := cryptoRand.Read(buf); err != nil { + return "", err + } value = base64.StdEncoding.EncodeToString(buf) }