Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/simpledeploy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ func runServe(cmd *cobra.Command, args []string) error {
srv.SetWebhookDispatcher(dispatcher)
auditRec := audit.NewRecorder(db)
auditRec.SetBus(eventBus)
auditRec.SetSyncTracking(srv.GitSyncPushing)
reconcilerAudit.SetSyncTracking(srv.GitSyncPushing)
srv.SetAudit(auditRec)
srv.SetBus(eventBus)
dep.SetAuditEmitter(&api.DeployerAuditAdapter{Rec: auditRec})
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ API keys inherit the creator's role and per-app grants at the moment of creation

## Rate limiting

A per-IP token bucket limits authentication attempts and dashboard requests. Defaults: 200 requests per 60 seconds with a burst of 50, keyed by IP. Configurable in `config.yaml`. Apps can override per-route via compose labels.
A per-IP token bucket limits authentication attempts and dashboard requests. Defaults: 200 requests per 60 seconds, keyed by IP. Configurable in `config.yaml` (`ratelimit.burst` and `ratelimit.by` are ignored here). Proxied app traffic has its own per-app limits from compose labels.

A separate, stricter limit on `/api/auth/login` blocks credential stuffing. After repeated failures, the account is temporarily locked.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ All three are registered in `init()` of their respective files in [/internal/pro

### `simpledeploy_ratelimit`

[/internal/proxy/ratelimit.go](https://github.com/vazra/simpledeploy/blob/main/internal/proxy/ratelimit.go). Per-domain configs registered via `RateLimiters.Set(domain, cfg)` from `SetRoutes`. Each domain has its own `domainLimiter` keyed by client IP (default), header value, or query param depending on `simpledeploy.ratelimit.by`. Each domain keeps at most 10,000 buckets: when full, buckets idle for a whole window are evicted first, then the least recently used. Keys longer than 128 bytes are stored as their SHA-256.
[/internal/proxy/ratelimit.go](https://github.com/vazra/simpledeploy/blob/main/internal/proxy/ratelimit.go). Per-domain configs registered via `RateLimiters.Set(domain, cfg)` from `SetRoutes`. Each domain has its own `domainLimiter` keyed by client IP (default), header value, or path depending on `simpledeploy.ratelimit.by`. Each bucket allows `requests` per window; a window starts at the bucket's first request after the previous one ended. With `burst` > 0, up to `burst` more requests pass once the window's `requests` are used up. The burst refills only after a whole window within `requests` (or with no requests), so a client that keeps going over is held to `requests` per window. `burst` 0 (label unset) is a plain fixed window. Each domain keeps at most 10,000 buckets: when full, buckets idle for a whole window are evicted first, then the least recently used. Keys longer than 128 bytes are stored as their SHA-256.

### `simpledeploy_ipaccess`

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/activity-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Each entry stores: actor, source (UI / API / CLI / git sync / system), category,

## Sync eligibility

Entries for config-change categories are marked with a sync status when [git sync](/operations/git-sync/) is enabled. The git sync worker stamps each relevant entry with `synced`, `pending`, or `failed` after the push completes. Runtime events (deploys, auth, start/stop) are not git-tracked and carry no badge.
Entries for config-change categories are marked with a sync status when [git sync](/operations/git-sync/) is enabled and pushes changes (auto-push on); entries recorded while it is off carry no badge. The git sync worker stamps each relevant entry with `synced`, `pending`, or `failed` after the push completes. Runtime events (deploys, auth, start/stop) are not git-tracked and carry no badge.

## Retention

Expand Down
8 changes: 4 additions & 4 deletions docs/guides/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ Rate limiting is per-app, configured with `simpledeploy.ratelimit.*` labels on a

| Label | Default | Description |
|-------|---------|-------------|
| `simpledeploy.ratelimit.requests` | `200` (server config) | Allowed requests per window |
| `simpledeploy.ratelimit.requests` | none | Allowed requests per window. Rate limiting is on only when this is set |
| `simpledeploy.ratelimit.window` | `60s` | Length of the window |
| `simpledeploy.ratelimit.burst` | `50` | Extra short-spike allowance over the steady rate |
| `simpledeploy.ratelimit.burst` | `0` | Extra requests allowed in a short spike, on top of `requests` |
| `simpledeploy.ratelimit.by` | `ip` | Key to bucket by: `ip`, `header:NAME`, or `path` |

`requests`/`window` define the steady rate. `burst` lets a client briefly exceed it before getting throttled. Once the bucket is empty, clients get `429 Too Many Requests` with a `Retry-After: 60` header.
`requests`/`window` define the steady rate. `burst` lets a client briefly go over it: once a window's `requests` are used up, up to `burst` more get through. A client that keeps going over is held to `requests` per window; the burst comes back after it stays within `requests` (or sends nothing) for a whole window. Without `burst`, the limit is exactly `requests` per window. Once nothing is left, clients get `429 Too Many Requests` with a `Retry-After: 60` header.

## Per-IP (most common)

Expand All @@ -31,7 +31,7 @@ services:
simpledeploy.ratelimit.by: "ip"
```

100 req/min/IP, with burst up to 120.
100 req/min/IP. A short spike can reach 120 in one minute; a client that keeps going over is held to 100 a minute.

## Per API key (header)

Expand Down
3 changes: 1 addition & 2 deletions docs/guides/small-vps-tuning.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ Defaults assume a beefier host. On 1 vCPU:
ratelimit:
requests: 100
window: 60s
burst: 25
```

This blocks bursty scrapers that would otherwise pin CPU.
This limits requests to the dashboard and management API. To slow down scrapers hitting your apps, add [rate limit labels](/guides/rate-limiting/) to those apps.

## Prune Docker regularly

Expand Down
2 changes: 1 addition & 1 deletion docs/operations/security-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Entries for config-change categories (`compose`, `endpoint`, `backup`, `alert`,
| Pending | Waiting for the next sync cycle |
| Sync failed | Commit or push failed; see git sync error details |

Runtime-only events (`deploy`, `auth`, `system`, `lifecycle` start/stop/scale) are never committed to git and show no badge.
Runtime-only events (`deploy`, `auth`, `system`, `lifecycle` start/stop/scale) are never committed to git and show no badge. Entries recorded while git sync is off, or while auto-push is off (pull-only), also show no badge.

## Retention

Expand Down
6 changes: 4 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ Set the IP allowlist. Body: `{"allow": "1.2.3.4, 10.0.0.0/8"}` (empty string all

## App Actions

Stop, start, scale and rollback wait for `docker compose` to finish before responding. The operation keeps running if the client disconnects mid-request.

### `POST /api/apps/{slug}/restart`

Restart all containers for the app. Returns `{"status": "ok"}`.
Restart the app's existing containers in place (`docker compose restart`). Containers are not recreated, so compose changes and newer images are not picked up (use pull or a deploy for that). Runs in the background and returns `202` with `{"status": "started"}`.

### `POST /api/apps/{slug}/stop`

Expand All @@ -149,7 +151,7 @@ Start stopped containers. Returns `{"status": "ok"}`.

### `POST /api/apps/{slug}/pull`

Pull latest images and redeploy. Uses configured registry auth if available. Returns `{"status": "ok"}`.
Pull latest images and redeploy. Uses configured registry auth if available. Runs in the background and returns `202` with `{"status": "started"}`.

### `POST /api/apps/{slug}/scale`

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/compose-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,10 @@ See [Rate limiting](/guides/rate-limiting/) for the full guide, including key ty

| Label | Default | Description |
|-------|---------|-------------|
| `simpledeploy.ratelimit.requests` | global default (200) | Max requests per window |
| `simpledeploy.ratelimit.window` | global default (60s) | Time window |
| `simpledeploy.ratelimit.burst` | global default (50) | Burst allowance above limit |
| `simpledeploy.ratelimit.by` | global default (ip) | Rate limit key |
| `simpledeploy.ratelimit.requests` | none (no limit) | Max requests per window. Required to turn rate limiting on |
| `simpledeploy.ratelimit.window` | `60s` | Time window |
| `simpledeploy.ratelimit.burst` | `0` | Extra requests allowed in a short spike, on top of `requests` |
| `simpledeploy.ratelimit.by` | `ip` | Rate limit key |

## Request Tracking Labels

Expand Down
13 changes: 6 additions & 7 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,11 @@ metrics:
- name: 1h
retention: 8760h # 1 year

# Global rate limit defaults (per-app labels override)
# Management API rate limit, per client IP. App traffic is limited only by
# per-app simpledeploy.ratelimit.* labels.
ratelimit:
requests: 200
window: 60s
burst: 50
by: ip # ip | header:X-API-Key | path

# Default registries applied to all apps (names reference stored registries)
registries:
Expand Down Expand Up @@ -121,10 +120,10 @@ git_sync:
| `master_secret` | string | **required** | Encryption/signing key |
| `audit_retention_days` | int | `365` | Activity log retention in days; `0` = keep forever |
| `metrics.tiers` | list | see above | Metrics rollup tiers |
| `ratelimit.requests` | int | `200` | Default requests per window |
| `ratelimit.window` | string | `60s` | Rate limit time window |
| `ratelimit.burst` | int | `50` | Burst allowance |
| `ratelimit.by` | string | `ip` | Rate limit key |
| `ratelimit.requests` | int | `200` | Management API requests per window, per client IP |
| `ratelimit.window` | string | `60s` | Management API rate limit window |
| `ratelimit.burst` | int | `50` | Reserved, currently ignored. Per-app burst is the `simpledeploy.ratelimit.burst` label |
| `ratelimit.by` | string | `ip` | Reserved, currently ignored (the management API always limits by IP) |
| `registries` | list | `[]` | Default registry names for all apps |
| `allowed_bind_paths` | list of string | `[]` | Absolute host folders apps may bind-mount even though they sit under a protected system folder (e.g. `["/home/media"]`). SimpleDeploy's data folder and other apps' folders stay protected. Do not list parents of sensitive folders such as `/var` or `/etc`. See [compose security validation](/reference/compose-labels/#compose-security-validation). |
| `git_sync.enabled` | bool | `false` | Enable two-way git sync |
Expand Down
37 changes: 29 additions & 8 deletions internal/api/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strconv"
"time"

"github.com/vazra/simpledeploy/internal/audit"
"github.com/vazra/simpledeploy/internal/compose"
Expand All @@ -28,6 +29,18 @@ type serviceInfo struct {
ScaleReason string `json:"scale_reason,omitempty"`
}

// composeOpTimeout bounds a docker compose operation that runs detached
// from its request, so a hung daemon cannot hold it forever.
const composeOpTimeout = 30 * time.Minute

// detachedCtx returns a context for a docker compose operation that must
// finish even if the client goes away. Closing the tab cancels r.Context(),
// which would kill the compose process mid-way (e.g. leave 1 of 2 replicas).
// Request values (actor for audit) are kept.
func detachedCtx(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(r.Context()), composeOpTimeout)
}

func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if s.reconciler == nil {
Expand Down Expand Up @@ -58,12 +71,14 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reconciler not configured", http.StatusInternalServerError)
return
}
if err := s.reconciler.StopOne(r.Context(), slug); err != nil {
ctx, cancel := detachedCtx(r)
defer cancel()
if err := s.reconciler.StopOne(ctx, slug); err != nil {
httpError(w, err, http.StatusInternalServerError)
return
}
afterJSON, _ := json.Marshal(map[string]any{"name": slug, "status": "stopped"})
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
_, _ = s.audit.Record(ctx, audit.RecordReq{
Category: "lifecycle",
Action: "stopped",
AppSlug: slug,
Expand All @@ -79,12 +94,14 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reconciler not configured", http.StatusInternalServerError)
return
}
if err := s.reconciler.StartOne(r.Context(), slug); err != nil {
ctx, cancel := detachedCtx(r)
defer cancel()
if err := s.reconciler.StartOne(ctx, slug); err != nil {
httpError(w, err, http.StatusInternalServerError)
return
}
afterJSON, _ := json.Marshal(map[string]any{"name": slug, "status": "running"})
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
_, _ = s.audit.Record(ctx, audit.RecordReq{
Category: "lifecycle",
Action: "started",
AppSlug: slug,
Expand Down Expand Up @@ -174,7 +191,9 @@ func (s *Server) handleScale(w http.ResponseWriter, r *http.Request) {
}
}

if err := s.reconciler.ScaleOne(r.Context(), slug, body.Scales); err != nil {
ctx, cancel := detachedCtx(r)
defer cancel()
if err := s.reconciler.ScaleOne(ctx, slug, body.Scales); err != nil {
// Forward the actual error (which wraps docker compose stderr) so the
// UI can show something more useful than "Internal Server Error".
log.Printf("[api] scale %s: %v", slug, err)
Expand All @@ -184,7 +203,7 @@ func (s *Server) handleScale(w http.ResponseWriter, r *http.Request) {

beforeJSON, _ := json.Marshal(map[string]any{"name": slug, "replicas": beforeReplicas})
afterJSON, _ := json.Marshal(map[string]any{"name": slug, "replicas": afterReplicas})
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
_, _ = s.audit.Record(ctx, audit.RecordReq{
Category: "lifecycle",
Action: "scaled",
AppSlug: slug,
Expand Down Expand Up @@ -346,7 +365,9 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
if _, ok := s.checkVersionForRestore(w, r, slug, filepath.Join(s.appsDir, slug, "docker-compose.yml"), ver); !ok {
return
}
if err := s.reconciler.RollbackOne(r.Context(), slug, body.VersionID); err != nil {
ctx, cancel := detachedCtx(r)
defer cancel()
if err := s.reconciler.RollbackOne(ctx, slug, body.VersionID); err != nil {
if v, ok := violationsOf(err); ok {
writeViolations(w, http.StatusBadRequest, versionRefused, v)
return
Expand All @@ -359,7 +380,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
return
}
afterJSON, _ := json.Marshal(map[string]any{"version_id": body.VersionID})
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
_, _ = s.audit.Record(ctx, audit.RecordReq{
Category: "lifecycle",
Action: "rolled_back",
AppSlug: slug,
Expand Down
115 changes: 115 additions & 0 deletions internal/api/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/vazra/simpledeploy/internal/compose"
"github.com/vazra/simpledeploy/internal/deployer"
Expand Down Expand Up @@ -215,3 +218,115 @@ func TestScaleEndpointMissingBody(t *testing.T) {
t.Fatalf("status = %d, want 400", w.Code)
}
}

// blockingRunner is a deployer.CommandRunner that hands each compose call's
// context to the test and blocks until released. Like exec.CommandContext,
// it fails if that context is canceled.
type blockingRunner struct {
ctxs chan context.Context
release chan struct{}
}

func (b *blockingRunner) Run(ctx context.Context, _ string, args ...string) (string, string, error) {
if len(args) == 2 && args[1] == "version" {
return "", "", nil
}
b.ctxs <- ctx
select {
case <-b.release:
case <-ctx.Done():
}
return "", "", ctx.Err()
}

// runnerReconciler routes lifecycle actions through a real deployer so the
// context reaching the CommandRunner is the one the handler passed.
type runnerReconciler struct {
mockReconcilerFull
d *deployer.Deployer
runner deployer.CommandRunner
composePath string
}

func (m *runnerReconciler) StopOne(ctx context.Context, slug string) error {
return m.d.Stop(ctx, slug)
}
func (m *runnerReconciler) StartOne(ctx context.Context, slug string) error {
return m.d.Start(ctx, slug)
}
func (m *runnerReconciler) ScaleOne(ctx context.Context, slug string, scales map[string]int) error {
return m.d.Scale(ctx, &compose.AppConfig{Name: slug, ComposePath: m.composePath}, scales)
}
func (m *runnerReconciler) RollbackOne(ctx context.Context, _ string, _ int64) error {
_, _, err := m.runner.Run(ctx, "docker", "compose", "up", "-d")
return err
}

func TestLifecycleActionsSurviveClientDisconnect(t *testing.T) {
scale, _ := json.Marshal(map[string]any{"scales": map[string]int{"web": 2}})
cases := []struct {
name, path string
body []byte
rollback bool
}{
{"stop", "/api/apps/myapp/stop", nil, false},
{"start", "/api/apps/myapp/start", nil, false},
{"scale", "/api/apps/myapp/scale", scale, false},
{"rollback", "/api/apps/myapp/rollback", nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, _ := newTestServer(t)
srv.SetAppsDir(t.TempDir())
runner := &blockingRunner{ctxs: make(chan context.Context, 1), release: make(chan struct{})}
d, err := deployer.New(runner)
if err != nil {
t.Fatal(err)
}
composePath := filepath.Join(t.TempDir(), "docker-compose.yml")
if err := os.WriteFile(composePath, []byte("services:\n web:\n image: nginx\n"), 0o644); err != nil {
t.Fatal(err)
}
srv.SetReconciler(&runnerReconciler{d: d, runner: runner, composePath: composePath})

body := tc.body
if tc.rollback {
body, _ = json.Marshal(map[string]any{"version_id": seedVersion(t, srv, "myapp")})
}
reqCtx, closeTab := context.WithCancel(context.Background())
defer closeTab()
req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(body)).WithContext(reqCtx)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(superAdminCookie(t, srv.jwt))
w := httptest.NewRecorder()
done := make(chan struct{})
go func() {
srv.Handler().ServeHTTP(w, req)
close(done)
}()

var opCtx context.Context
select {
case opCtx = <-runner.ctxs:
case <-time.After(5 * time.Second):
t.Fatal("compose command never ran")
}
closeTab()
if err := opCtx.Err(); err != nil {
t.Fatalf("compose command context canceled with the request: %v", err)
}
if _, ok := opCtx.Deadline(); !ok {
t.Error("compose command context has no timeout")
}
close(runner.release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("handler did not return")
}
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
})
}
}
Loading
Loading