forked from wrenn/wrenn
feat: add notification channels with provider integrations and retry
Implement a channels system for notifying teams via external providers
(Discord, Slack, Teams, Google Chat, Telegram, Matrix, webhook) when
lifecycle events occur (capsule/template/host state changes).
- Channel CRUD API under /v1/channels (JWT-only auth)
- Test endpoint to verify config before saving (POST /v1/channels/test)
- Secret rotation endpoint (PUT /v1/channels/{id}/config)
- AES-256-GCM encryption for provider secrets (WRENN_ENCRYPTION_KEY)
- Redis stream event publishing from audit logger
- Background dispatcher with consumer group and retry (10s, 30s)
- Webhook delivery with HMAC-SHA256 signing (X-WRENN-SIGNATURE)
- shoutrrr integration for chat providers
- Secrets never exposed in API responses
This commit is contained in:
235
internal/api/handlers_channels.go
Normal file
235
internal/api/handlers_channels.go
Normal file
@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/channels"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||
)
|
||||
|
||||
type channelHandler struct {
|
||||
svc *channels.Service
|
||||
}
|
||||
|
||||
func newChannelHandler(svc *channels.Service) *channelHandler {
|
||||
return &channelHandler{svc: svc}
|
||||
}
|
||||
|
||||
type createChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Config map[string]string `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
type rotateConfigRequest struct {
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type testChannelRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
TeamID string `json:"team_id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Events []string `json:"events"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Secret *string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
func channelToResponse(ch db.Channel) channelResponse {
|
||||
resp := channelResponse{
|
||||
ID: id.FormatChannelID(ch.ID),
|
||||
TeamID: id.FormatTeamID(ch.TeamID),
|
||||
Name: ch.Name,
|
||||
Provider: ch.Provider,
|
||||
Events: ch.EventTypes,
|
||||
}
|
||||
if ch.CreatedAt.Valid {
|
||||
resp.CreatedAt = ch.CreatedAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
if ch.UpdatedAt.Valid {
|
||||
resp.UpdatedAt = ch.UpdatedAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// Create handles POST /v1/channels.
|
||||
func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
|
||||
var req createChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.Create(r.Context(), channels.CreateParams{
|
||||
TeamID: ac.TeamID,
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Config: req.Config,
|
||||
Events: req.Events,
|
||||
})
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
resp := channelToResponse(result.Channel)
|
||||
if result.PlaintextSecret != "" {
|
||||
resp.Secret = &result.PlaintextSecret
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// List handles GET /v1/channels.
|
||||
func (h *channelHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
|
||||
chs, err := h.svc.List(r.Context(), ac.TeamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list channels")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]channelResponse, len(chs))
|
||||
for i, ch := range chs {
|
||||
resp[i] = channelToResponse(ch)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Get handles GET /v1/channels/{id}.
|
||||
func (h *channelHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
channelIDStr := chi.URLParam(r, "id")
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.Get(r.Context(), channelID, ac.TeamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "channel not found")
|
||||
} else {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get channel")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
||||
}
|
||||
|
||||
// Update handles PATCH /v1/channels/{id}.
|
||||
func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
channelIDStr := chi.URLParam(r, "id")
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req updateChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.Update(r.Context(), channelID, ac.TeamID, req.Name, req.Events)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
||||
}
|
||||
|
||||
// Test handles POST /v1/channels/test.
|
||||
func (h *channelHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
var req testChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Test(r.Context(), req.Provider, req.Config); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// RotateConfig handles PUT /v1/channels/{id}/config.
|
||||
func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
channelIDStr := chi.URLParam(r, "id")
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req rotateConfigRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.svc.RotateConfig(r.Context(), channelID, ac.TeamID, req.Config)
|
||||
if err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, channelToResponse(ch))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /v1/channels/{id}.
|
||||
func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
channelIDStr := chi.URLParam(r, "id")
|
||||
|
||||
channelID, err := id.ParseChannelID(channelIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid channel ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Delete(r.Context(), channelID, ac.TeamID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete channel")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@ -95,6 +95,8 @@ func serviceErrToHTTP(err error) (int, string, string) {
|
||||
return http.StatusNotFound, "not_found", msg
|
||||
case strings.Contains(msg, "not running"), strings.Contains(msg, "not paused"):
|
||||
return http.StatusConflict, "invalid_state", msg
|
||||
case strings.Contains(msg, "conflict:"):
|
||||
return http.StatusConflict, "conflict", msg
|
||||
case strings.Contains(msg, "forbidden"):
|
||||
return http.StatusForbidden, "forbidden", msg
|
||||
case strings.Contains(msg, "invalid or expired"):
|
||||
|
||||
@ -1547,6 +1547,176 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/channels:
|
||||
post:
|
||||
summary: Create a notification channel
|
||||
operationId: createChannel
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateChannelRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Channel created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
get:
|
||||
summary: List notification channels
|
||||
operationId: listChannels
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Channels list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
|
||||
/v1/channels/test:
|
||||
post:
|
||||
summary: Test a channel configuration
|
||||
description: >
|
||||
Sends a test notification using the provided provider and config without
|
||||
saving anything. Use this to verify credentials before creating a channel.
|
||||
operationId: testChannel
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TestChannelRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Test notification sent successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
|
||||
/v1/channels/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
summary: Get a notification channel
|
||||
operationId: getChannel
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Channel details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
"404":
|
||||
description: Channel not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
patch:
|
||||
summary: Update a notification channel
|
||||
operationId: updateChannel
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateChannelRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Channel updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Channel not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
delete:
|
||||
summary: Delete a notification channel
|
||||
operationId: deleteChannel
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"204":
|
||||
description: Channel deleted
|
||||
|
||||
/v1/channels/{id}/config:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
put:
|
||||
summary: Rotate channel secrets
|
||||
description: >
|
||||
Replaces the channel's provider configuration entirely with new secrets.
|
||||
The previous config is discarded. Config fields must match the provider's
|
||||
required fields.
|
||||
operationId: rotateChannelConfig
|
||||
tags: [channels]
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RotateConfigRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Config rotated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChannelResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
description: Channel not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
@ -2067,6 +2237,112 @@ components:
|
||||
format: int64
|
||||
description: "Allocated disk bytes for the CoW sparse file"
|
||||
|
||||
CreateChannelRequest:
|
||||
type: object
|
||||
required: [name, provider, config, events]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Unique channel name within the team.
|
||||
provider:
|
||||
type: string
|
||||
enum: [discord, slack, teams, googlechat, telegram, matrix, webhook]
|
||||
config:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: >
|
||||
Provider-specific configuration fields.
|
||||
Discord/Slack/Teams/Google Chat: {"webhook_url": "..."}.
|
||||
Telegram: {"bot_token": "...", "chat_id": "..."}.
|
||||
Matrix: {"homeserver_url": "...", "access_token": "...", "room_id": "..."}.
|
||||
Webhook: {"url": "...", "secret": "..."} (secret is auto-generated if omitted).
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- capsule.created
|
||||
- capsule.running
|
||||
- capsule.paused
|
||||
- capsule.destroyed
|
||||
- template.snapshot.created
|
||||
- template.snapshot.deleted
|
||||
- host.up
|
||||
- host.down
|
||||
|
||||
TestChannelRequest:
|
||||
type: object
|
||||
required: [provider, config]
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [discord, slack, teams, googlechat, telegram, matrix, webhook]
|
||||
config:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Provider-specific configuration fields (same as CreateChannelRequest.config).
|
||||
|
||||
RotateConfigRequest:
|
||||
type: object
|
||||
required: [config]
|
||||
properties:
|
||||
config:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: >
|
||||
New provider configuration fields. Must include all required fields
|
||||
for the channel's provider. Replaces the existing config entirely.
|
||||
|
||||
UpdateChannelRequest:
|
||||
type: object
|
||||
required: [name, events]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- capsule.created
|
||||
- capsule.running
|
||||
- capsule.paused
|
||||
- capsule.destroyed
|
||||
- template.snapshot.created
|
||||
- template.snapshot.deleted
|
||||
- host.up
|
||||
- host.down
|
||||
|
||||
ChannelResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
team_id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
enum: [discord, slack, teams, googlechat, telegram, matrix, webhook]
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
secret:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Webhook secret. Only returned on creation, never again.
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"git.omukk.dev/wrenn/sandbox/internal/audit"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/auth/oauth"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/channels"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/lifecycle"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/scheduler"
|
||||
@ -38,6 +39,8 @@ func New(
|
||||
oauthRegistry *oauth.Registry,
|
||||
oauthRedirectURL string,
|
||||
ca *auth.CA,
|
||||
al *audit.AuditLogger,
|
||||
channelSvc *channels.Service,
|
||||
) *Server {
|
||||
r := chi.NewRouter()
|
||||
r.Use(requestLogger())
|
||||
@ -52,8 +55,6 @@ func New(
|
||||
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
||||
buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched}
|
||||
|
||||
al := audit.New(queries)
|
||||
|
||||
sandbox := newSandboxHandler(sandboxSvc, al)
|
||||
exec := newExecHandler(queries, pool)
|
||||
execStream := newExecStreamHandler(queries, pool)
|
||||
@ -70,6 +71,7 @@ func New(
|
||||
statsH := newStatsHandler(statsSvc)
|
||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||
channelH := newChannelHandler(channelSvc)
|
||||
|
||||
// OpenAPI spec and docs.
|
||||
r.Get("/openapi.yaml", serveOpenAPI)
|
||||
@ -171,6 +173,20 @@ func New(
|
||||
})
|
||||
})
|
||||
|
||||
// JWT-authenticated: notification channels.
|
||||
r.Route("/v1/channels", func(r chi.Router) {
|
||||
r.Use(requireJWT(jwtSecret))
|
||||
r.Post("/", channelH.Create)
|
||||
r.Get("/", channelH.List)
|
||||
r.Post("/test", channelH.Test)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", channelH.Get)
|
||||
r.Patch("/", channelH.Update)
|
||||
r.Delete("/", channelH.Delete)
|
||||
r.Put("/config", channelH.RotateConfig)
|
||||
})
|
||||
})
|
||||
|
||||
// JWT-authenticated: audit log.
|
||||
r.With(requireJWT(jwtSecret)).Get("/v1/audit-logs", auditH.List)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user