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
1 change: 1 addition & 0 deletions cmd/simpledeploy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ func runServe(cmd *cobra.Command, args []string) error {
json.Unmarshal([]byte(decrypted), &s3cfg)
return backup.NewS3Target(s3cfg)
})
backupSched.SetEventBus(eventBus)
backupSched.SetAlertFunc(func(appID int64, appName, strategy, message, eventType string) {
evaluator.DispatchBackupAlert(alerts.BackupAlertEvent{
AppID: appID,
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ SimpleDeploy keeps the dashboard fresh without polling by broadcasting tiny noti
| `global:users` | users, role changes, access grants/revokes, API keys |
| `global:registries`| registry CRUD |
| `global:alerts` | webhooks, alert rules |
| `global:backups` | any backup config or run |
| `global:backups` | any backup config change; backup and restore runs starting and finishing |
| `global:docker` | docker prune actions |
| `global:audit` | every audit-recorded mutation |

Expand All @@ -62,7 +62,7 @@ No payload data ever ships in events. The UI runs its existing REST refetch when

## Authorization

Topic ACL is computed when the WS opens. `super_admin` sees every global topic; everyone else sees `global:apps`, `global:backups`, `global:alerts` plus `app:<slug>` for any slug they have `user_app_access` on. A `sub` for a forbidden topic returns `{op:"err", reason:"forbidden"}`. When an `access` or `user` audit event affects the connected user, the server closes the socket so the client reconnects with fresh authz.
Topic ACL is computed when the WS opens. `super_admin` sees every global topic; everyone else sees `global:apps`, `global:backups`, `global:alerts` plus `app:<slug>` for any slug they have `user_app_access` on. A `sub` for a forbidden topic returns `{op:"err", reason:"forbidden"}`. An event about one app (`Event.App` set, never sent to clients) is delivered only to users who can view that app, on any topic: the backup scheduler publishes `backup.run` on both `app:<slug>` and `global:backups` when a run starts and finishes, and a user with access to other apps does not learn that this app's backups ran. When an `access` or `user` audit event affects the connected user, the server closes the socket so the client reconnects with fresh authz.

The upgrade requires a matching `Origin` (host and port) or `Sec-Fetch-Site: same-origin`, like the log sockets (`checkWebSocketOrigin`, see [Auth](/architecture/auth/#cross-origin-protection)). Every 60s the socket re-runs `wsAuthStillValid`: if the user is gone, their role changed, or the session token or API key used to connect is no longer valid, it closes with code 1008. `app:` topics the user lost access to are dropped with an `err` frame. The app log, deploy log and process log sockets run the same 60s check and close on failure.

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/alerts/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ curl -X POST https://manage.yourdomain.com/api/alerts/rules \
| Field | Type | Notes |
|-------|------|-------|
| `app_id` | int | Target app (omit / null for system-wide; super_admin only) |
| `app_slug` | string | Target app by slug, instead of `app_id`. If both are set they must name the same app. An unknown slug is rejected with `400` |
| `metric` | string | `cpu_pct`, `mem_bytes`, `mem_pct`, or a backup event (below) |
| `operator` | string | `>`, `<`, `>=`, `<=` |
| `threshold` | float | Numeric threshold |
Expand All @@ -35,7 +36,7 @@ Set `metric` to a backup event to get a message when backups run:
| `backup_missed` | a scheduled backup is overdue (more than twice its interval) |
| `backup_all` | any of the above |

A rule with an `app_id` only reports that app's backups. A system-wide rule reports every app's backups. Backup rules send one message per event, so `threshold` and `duration_sec` are ignored (the API still requires an `operator`; use `>`).
A rule for an app (`app_id` or `app_slug`) only reports that app's backups. A system-wide rule reports every app's backups. Backup rules send one message per event, so `threshold` and `duration_sec` are ignored (the API still requires an `operator`; use `>`).

## Compose label shortcuts

Expand Down
6 changes: 4 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,14 @@ Create alert rule.
}
```

Metrics: `cpu_pct`, `mem_bytes`, `mem_pct`.
Metrics: `cpu_pct`, `mem_bytes`, `mem_pct`, and the backup events `backup_failed`, `backup_success`, `backup_missed`, `backup_all`.
Operators: `>`, `<`, `>=`, `<=`.

Name the app with `app_id` or `app_slug` (`"app_slug": "myapp"`). If both are set they must name the same app. Omit both for a system-wide rule (super_admin only). An unknown `app_slug` returns `400` for super_admin; `manage` users get `404` for apps they cannot manage, whether or not the app exists.

### `PUT /api/alerts/rules/{id}`

Update alert rule.
Update alert rule. Same body as `POST`; send the full rule, since an omitted app makes it system-wide.

### `DELETE /api/alerts/rules/{id}`

Expand Down
101 changes: 65 additions & 36 deletions internal/api/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,16 +304,64 @@ func alertRuleAuditJSON(r *store.AlertRule) []byte {
return b
}

func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request) {
var req struct {
AppID *int64 `json:"app_id"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
DurationSec int `json:"duration_sec"`
WebhookID int64 `json:"webhook_id"`
Enabled bool `json:"enabled"`
// alertRuleReq is the body of POST and PUT /api/alerts/rules. The app is
// named by app_id or app_slug (the UI sends the slug); neither means a
// global rule.
type alertRuleReq struct {
AppID *int64 `json:"app_id"`
AppSlug string `json:"app_slug"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
DurationSec int `json:"duration_sec"`
WebhookID int64 `json:"webhook_id"`
Enabled bool `json:"enabled"`
}

// alertRuleApp resolves the app a rule targets and checks the caller may
// mutate rules for it (canMutateForApp). If both app_id and app_slug are
// set they must name the same app. An unknown slug is a 400 for
// super_admin; other callers get the answer they would get for an app they
// cannot reach, so they cannot probe which apps exist. On failure the
// response is written and ok is false.
func (s *Server) alertRuleApp(w http.ResponseWriter, r *http.Request, req *alertRuleReq) (appID *int64, appSlug string, ok bool) {
if req.AppSlug == "" {
if !s.canMutateForApp(w, r, req.AppID) {
return nil, "", false
}
if req.AppID != nil {
if app, err := s.store.GetAppByID(*req.AppID); err == nil {
appSlug = app.Slug
}
}
return req.AppID, appSlug, true
}
app, err := s.store.GetAppBySlug(req.AppSlug)
if err != nil {
if u := GetAuthUser(r); u != nil && u.Role == "super_admin" {
http.Error(w, fmt.Sprintf("app %q not found", req.AppSlug), http.StatusBadRequest)
return nil, "", false
}
// No app has id 0, so this writes the same 401/403/404 as for an
// existing app the caller has no access to.
var none int64
if s.canMutateForApp(w, r, &none) {
http.Error(w, "not found", http.StatusNotFound)
}
return nil, "", false
}
if !s.canMutateForApp(w, r, &app.ID) {
return nil, "", false
}
if req.AppID != nil && *req.AppID != app.ID {
http.Error(w, "app_id and app_slug name different apps", http.StatusBadRequest)
return nil, "", false
}
return &app.ID, app.Slug, true
}

func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request) {
var req alertRuleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
Expand All @@ -326,7 +374,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request) {
http.Error(w, msg, http.StatusBadRequest)
return
}
if !s.canMutateForApp(w, r, req.AppID) {
appID, appSlug, ok := s.alertRuleApp(w, r, &req)
if !ok {
return
}
// verify webhook exists
Expand All @@ -335,7 +384,8 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request) {
return
}
rule := &store.AlertRule{
AppID: req.AppID,
AppID: appID,
AppSlug: appSlug,
Metric: req.Metric,
Operator: req.Operator,
Threshold: req.Threshold,
Expand All @@ -347,10 +397,6 @@ func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request) {
httpError(w, err, http.StatusInternalServerError)
return
}
var appID *int64
if req.AppID != nil {
appID = req.AppID
}
afterJSON := alertRuleAuditJSON(rule)
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
Category: "alert",
Expand Down Expand Up @@ -400,15 +446,7 @@ func (s *Server) handleUpdateAlertRule(w http.ResponseWriter, r *http.Request) {
}
}
}
var req struct {
AppID *int64 `json:"app_id"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
DurationSec int `json:"duration_sec"`
WebhookID int64 `json:"webhook_id"`
Enabled bool `json:"enabled"`
}
var req alertRuleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
Expand All @@ -422,18 +460,13 @@ func (s *Server) handleUpdateAlertRule(w http.ResponseWriter, r *http.Request) {
if existing != nil && !s.canMutateForApp(w, r, existing.AppID) {
return
}
if !s.canMutateForApp(w, r, req.AppID) {
appID, appSlug, ok := s.alertRuleApp(w, r, &req)
if !ok {
return
}
var appSlug string
if req.AppID != nil {
if app, err := s.store.GetAppByID(*req.AppID); err == nil {
appSlug = app.Slug
}
}
rule := &store.AlertRule{
ID: id,
AppID: req.AppID,
AppID: appID,
AppSlug: appSlug,
Metric: req.Metric,
Operator: req.Operator,
Expand All @@ -451,10 +484,6 @@ func (s *Server) handleUpdateAlertRule(w http.ResponseWriter, r *http.Request) {
beforeJSON = alertRuleAuditJSON(existing)
}
afterJSON := alertRuleAuditJSON(rule)
var appID *int64
if req.AppID != nil {
appID = req.AppID
}
_, _ = s.audit.Record(r.Context(), audit.RecordReq{
Category: "alert",
Action: "changed",
Expand Down
Loading
Loading