Compare commits
10 Commits
1cbe58888c
...
3e84b9c3ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e84b9c3ad | |||
| f060c09668 | |||
| fa813c011c | |||
| ac3ba35736 | |||
| a35b3e6283 | |||
| c04db89633 | |||
| 298c960808 | |||
| 71b02758d7 | |||
| 4e594d3a7d | |||
| 5eaf6fe6fb |
+11
-6
@@ -1,5 +1,10 @@
|
|||||||
# Build stage
|
# Build stage — pinned to the build host's arch so cross-compilation
|
||||||
FROM golang:1.24-alpine AS builder
|
# via GOOS/GOARCH is fast and avoids QEMU emulation of the toolchain.
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
# Platform args populated by buildx for each target in a multi-platform build.
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Build arguments
|
# Build arguments
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
@@ -29,13 +34,13 @@ COPY . .
|
|||||||
# Generate protobuf code
|
# Generate protobuf code
|
||||||
RUN make proto-gen
|
RUN make proto-gen
|
||||||
|
|
||||||
# Build binary with version information
|
# Build binary with version information, cross-compiling to the target arch.
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo \
|
||||||
-ldflags "-X main.Version=${VERSION} -X main.GitCommit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME} ${BUILD_FLAGS}" \
|
-ldflags "-X main.Version=${VERSION} -X main.GitCommit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME} ${BUILD_FLAGS}" \
|
||||||
-o server ./cmd/server
|
-o server ./cmd/server
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM alpine:latest
|
FROM alpine:3.21
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk --no-cache add ca-certificates tzdata
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
@@ -64,5 +69,5 @@ USER notifier
|
|||||||
EXPOSE 8080 50051 9090 8081
|
EXPOSE 8080 50051 9090 8081
|
||||||
|
|
||||||
# Run server (defaults to both REST and gRPC)
|
# Run server (defaults to both REST and gRPC)
|
||||||
# Override mode with environment variable: -e SERVER_MODE=rest or -e SERVER_MODE=grpc
|
# Override mode with environment variable: -e NOTIFIER_SERVER_MODE=rest or -e NOTIFIER_SERVER_MODE=grpc
|
||||||
CMD ["/app/server"]
|
CMD ["/app/server"]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
.PHONY: proto proto-gen proto-clean deps build build-dev run run-grpc run-rest test lint fmt vet check docker-build docker-build-dev docker-run clean help
|
.PHONY: proto proto-gen proto-clean deps build build-dev run run-grpc run-rest test lint fmt vet check docker-build docker-build-dev docker-buildx-setup docker-run clean help
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
|
REGISTRY ?=
|
||||||
|
IMAGE ?= notifier
|
||||||
|
PLATFORMS ?= linux/amd64,linux/arm64
|
||||||
PROTO_DIR=api/grpc
|
PROTO_DIR=api/grpc
|
||||||
PROTO_FILE=$(PROTO_DIR)/notifier.proto
|
PROTO_FILE=$(PROTO_DIR)/notifier.proto
|
||||||
PROTO_OUT=$(PROTO_DIR)/pb
|
PROTO_OUT=$(PROTO_DIR)/pb
|
||||||
@@ -9,8 +12,8 @@ GO_FILES=$(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -path
|
|||||||
|
|
||||||
# Build information
|
# Build information
|
||||||
VERSION ?= dev
|
VERSION ?= dev
|
||||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S_UTC')
|
BUILD_TIME := $(shell date -u "+%Y-%m-%d_%H:%M:%S_UTC" 2>/dev/null || echo unknown)
|
||||||
|
|
||||||
# Base LDFLAGS (version info only)
|
# Base LDFLAGS (version info only)
|
||||||
LDFLAGS_BASE := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)
|
LDFLAGS_BASE := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)
|
||||||
@@ -139,18 +142,37 @@ qa: fmt vet lint test
|
|||||||
@echo "All quality checks passed!"
|
@echo "All quality checks passed!"
|
||||||
|
|
||||||
# Build Docker image (production - optimized)
|
# Build Docker image (production - optimized)
|
||||||
|
# When REGISTRY is set, builds a multi-arch image and pushes
|
||||||
|
# $(REGISTRY)/$(IMAGE):$(VERSION) and :latest.
|
||||||
|
# Otherwise builds a single-arch local image tagged $(IMAGE):latest.
|
||||||
docker-build:
|
docker-build:
|
||||||
@echo "Building Docker image (production - optimized)..."
|
|
||||||
@echo "Version: $(VERSION)"
|
@echo "Version: $(VERSION)"
|
||||||
@echo "Git Commit: $(GIT_COMMIT)"
|
@echo "Git Commit: $(GIT_COMMIT)"
|
||||||
@echo "Build Time: $(BUILD_TIME)"
|
@echo "Build Time: $(BUILD_TIME)"
|
||||||
|
ifeq ($(strip $(REGISTRY)),)
|
||||||
|
@echo "Building single-arch local Docker image..."
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg VERSION=$(VERSION) \
|
--build-arg VERSION=$(VERSION) \
|
||||||
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
|
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
|
||||||
--build-arg BUILD_TIME=$(BUILD_TIME) \
|
--build-arg BUILD_TIME=$(BUILD_TIME) \
|
||||||
--build-arg BUILD_FLAGS="-s -w" \
|
--build-arg BUILD_FLAGS="-s -w" \
|
||||||
-t notifier:latest .
|
-t $(IMAGE):latest .
|
||||||
@echo "Docker image built successfully (production - optimized)"
|
@echo "Docker image built successfully (production - optimized)"
|
||||||
|
else
|
||||||
|
@$(MAKE) docker-buildx-setup
|
||||||
|
@echo "Building multi-arch image ($(PLATFORMS)) and pushing to $(REGISTRY)/$(IMAGE):$(VERSION)..."
|
||||||
|
docker buildx build \
|
||||||
|
--platform $(PLATFORMS) \
|
||||||
|
--build-arg VERSION=$(VERSION) \
|
||||||
|
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
|
||||||
|
--build-arg BUILD_TIME=$(BUILD_TIME) \
|
||||||
|
--build-arg BUILD_FLAGS="-s -w" \
|
||||||
|
--provenance=false \
|
||||||
|
--tag $(REGISTRY)/$(IMAGE):$(VERSION) \
|
||||||
|
--tag $(REGISTRY)/$(IMAGE):latest \
|
||||||
|
--push .
|
||||||
|
@echo "Multi-arch image pushed successfully"
|
||||||
|
endif
|
||||||
|
|
||||||
# Build Docker image with debug symbols (development)
|
# Build Docker image with debug symbols (development)
|
||||||
docker-build-dev:
|
docker-build-dev:
|
||||||
@@ -166,6 +188,14 @@ docker-build-dev:
|
|||||||
-t notifier:latest-dev .
|
-t notifier:latest-dev .
|
||||||
@echo "Docker image built successfully (development)"
|
@echo "Docker image built successfully (development)"
|
||||||
|
|
||||||
|
# Set up Docker buildx builder for multi-arch builds
|
||||||
|
docker-buildx-setup:
|
||||||
|
@echo "Setting up Docker buildx builder..."
|
||||||
|
@docker buildx inspect multiarch > /dev/null 2>&1 || docker buildx create --name multiarch --driver docker-container
|
||||||
|
@docker buildx use multiarch
|
||||||
|
@docker buildx inspect multiarch --bootstrap
|
||||||
|
@echo "Buildx builder ready"
|
||||||
|
|
||||||
# Run Docker container
|
# Run Docker container
|
||||||
docker-run:
|
docker-run:
|
||||||
@echo "Running Docker container..."
|
@echo "Running Docker container..."
|
||||||
@@ -212,5 +242,15 @@ help:
|
|||||||
@echo " deps - Install Go dependencies"
|
@echo " deps - Install Go dependencies"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Docker:"
|
@echo "Docker:"
|
||||||
@echo " docker-build - Build Docker image"
|
@echo " docker-build - Build Docker image"
|
||||||
@echo " docker-run - Run Docker container"
|
@echo " - default: single-arch local image"
|
||||||
|
@echo " - with REGISTRY set: multi-arch (amd64+arm64) build + push"
|
||||||
|
@echo " docker-build-dev - Build Docker image with debug symbols"
|
||||||
|
@echo " docker-buildx-setup - Set up buildx builder for multi-arch"
|
||||||
|
@echo " docker-run - Run Docker container locally"
|
||||||
|
@echo ""
|
||||||
|
@echo "Variables:"
|
||||||
|
@echo " VERSION - Image version tag (default: dev)"
|
||||||
|
@echo " REGISTRY - Registry prefix for docker-build (e.g. registry.example.com/org)"
|
||||||
|
@echo " IMAGE - Image name (default: notifier)"
|
||||||
|
@echo " PLATFORMS - Build platforms (default: $(PLATFORMS))"
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ Server starts with:
|
|||||||
|
|
||||||
**Run in different modes:**
|
**Run in different modes:**
|
||||||
```bash
|
```bash
|
||||||
./bin/server # Both REST and gRPC (default)
|
./bin/server # Both REST and gRPC (default)
|
||||||
SERVER_MODE=rest ./bin/server # REST only
|
NOTIFIER_SERVER_MODE=rest ./bin/server # REST only
|
||||||
SERVER_MODE=grpc ./bin/server # gRPC only
|
NOTIFIER_SERVER_MODE=grpc ./bin/server # gRPC only
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Send Your First Notification
|
### 2. Send Your First Notification
|
||||||
@@ -284,15 +284,32 @@ Format: `NOTIFIER_<SECTION>_<KEY>` (use `_` for nested keys)
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `GET` | `/health` | Health check |
|
| `GET` | `/health` | Health check (always unauthenticated) |
|
||||||
| `POST` | `/api/v1/notifications` | Send single notification |
|
| `POST` | `/api/v1/notifications` | Send single notification (returns `202 Accepted`) |
|
||||||
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications |
|
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications (returns `202 Accepted`) |
|
||||||
| `GET` | `/api/v1/notifications` | List notifications (with filters) |
|
| `GET` | `/api/v1/notifications` | List notifications (with filters) |
|
||||||
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
|
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
|
||||||
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
|
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
|
||||||
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification |
|
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification |
|
||||||
|
| `GET` | `/api/v1/notifiers` | List configured notifier types and accounts |
|
||||||
| `GET` | `/api/v1/stats` | Get service statistics |
|
| `GET` | `/api/v1/stats` | Get service statistics |
|
||||||
|
|
||||||
|
When `auth.enabled` is set, all `/api/v1` routes require an API key (see
|
||||||
|
[Authentication](#authentication)); `/health` is always open.
|
||||||
|
|
||||||
|
#### Admin — API Key Management
|
||||||
|
|
||||||
|
Registered only when authentication **and** a key store are configured. See
|
||||||
|
[docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) for the full workflow.
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `POST` | `/api/v1/admin/keys` | Create an API key |
|
||||||
|
| `GET` | `/api/v1/admin/keys` | List API keys |
|
||||||
|
| `DELETE` | `/api/v1/admin/keys/{name}` | Revoke an API key |
|
||||||
|
| `POST` | `/api/v1/admin/keys/{name}/rotate` | Rotate an API key |
|
||||||
|
| `GET` | `/api/v1/admin/keys/{name}/audit` | Get a key's audit log |
|
||||||
|
|
||||||
### Request Format
|
### Request Format
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -352,9 +369,30 @@ curl "http://localhost:8080/api/v1/notifications?type=email&status=sent&limit=10
|
|||||||
curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20"
|
curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Supported query parameters: `type` and `status` (both repeatable),
|
||||||
|
`recipient` (repeatable), `limit`, and `offset`.
|
||||||
|
|
||||||
## gRPC API
|
## gRPC API
|
||||||
|
|
||||||
The gRPC service mirrors the REST API with full feature parity. See [api/grpc/notifier.proto](api/grpc/notifier.proto) for definitions.
|
The gRPC service mirrors the REST API with full feature parity. The service is
|
||||||
|
`notifier.v1.NotifierService` (RPCs: `SendNotification`, `SendBatchNotifications`,
|
||||||
|
`GetNotification`, `ListNotifications`, `CancelNotification`, `RetryNotification`,
|
||||||
|
`GetStats`, `GetNotifiers`, `HealthCheck`). See
|
||||||
|
[api/grpc/notifier.proto](api/grpc/notifier.proto) for the full definitions.
|
||||||
|
|
||||||
|
Server [reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)
|
||||||
|
is enabled, so tools like `grpcurl` work without a local copy of the proto:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List methods
|
||||||
|
grpcurl -plaintext localhost:50051 list notifier.v1.NotifierService
|
||||||
|
|
||||||
|
# Call GetNotifiers
|
||||||
|
grpcurl -plaintext localhost:50051 notifier.v1.NotifierService/GetNotifiers
|
||||||
|
```
|
||||||
|
|
||||||
|
When `auth.enabled` is set, pass the API key as metadata
|
||||||
|
(`-H "authorization: Bearer <key>"`); see [docs/AUTH.md](docs/AUTH.md).
|
||||||
|
|
||||||
**Generate Go code:**
|
**Generate Go code:**
|
||||||
```bash
|
```bash
|
||||||
@@ -363,17 +401,55 @@ make proto-gen
|
|||||||
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto
|
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** gRPC server is running but handler implementation is pending. Protobuf definitions are complete.
|
## Authentication
|
||||||
|
|
||||||
|
Authentication is **disabled by default** (`auth.enabled: false`). When enabled,
|
||||||
|
the service uses API keys with optional role-based access control (RBAC) over
|
||||||
|
both the REST and gRPC APIs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
default_rate_limit: 100 # requests per minute per key
|
||||||
|
bootstrap:
|
||||||
|
enabled: true # mint an admin key on first start
|
||||||
|
print_to_stdout: false
|
||||||
|
kubernetes_secret_name: notifier-admin-key
|
||||||
|
kubernetes_secret_key: admin-key
|
||||||
|
```
|
||||||
|
|
||||||
|
- **REST:** send `Authorization: Bearer <key>` (or `X-API-Key: <key>`).
|
||||||
|
- **gRPC:** send an `authorization: Bearer <key>` metadata header.
|
||||||
|
- Keys can be managed at runtime via the [admin endpoints](#admin--api-key-management).
|
||||||
|
- An admin key can be bootstrapped to stdout, a file, or a Kubernetes Secret.
|
||||||
|
|
||||||
|
See the guides for details:
|
||||||
|
- [docs/AUTH_QUICK_START.md](docs/AUTH_QUICK_START.md) — 5-minute setup
|
||||||
|
- [docs/AUTH.md](docs/AUTH.md) — full authentication & client examples
|
||||||
|
- [docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) — key lifecycle (create/rotate/revoke/audit)
|
||||||
|
- [docs/RBAC.md](docs/RBAC.md) — role-based access to notifier accounts
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
**Build:**
|
**Build (single-arch, local):**
|
||||||
```bash
|
```bash
|
||||||
docker build -t notifier:latest .
|
make docker-build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Build and push a multi-arch image (linux/amd64 + linux/arm64):**
|
||||||
|
```bash
|
||||||
|
# Pushes $REGISTRY/$IMAGE:$VERSION and :latest.
|
||||||
|
# Requires Docker buildx (bundled with Docker Desktop; on Linux also run
|
||||||
|
# `docker run --rm --privileged tonistiigi/binfmt --install all` once to
|
||||||
|
# register the QEMU emulators).
|
||||||
|
REGISTRY=registry.example.com/org VERSION=v0.1.3 make docker-build
|
||||||
|
```
|
||||||
|
|
||||||
|
Override `IMAGE` (default `notifier`) or `PLATFORMS` (default
|
||||||
|
`linux/amd64,linux/arm64`) as needed.
|
||||||
|
|
||||||
**Run:**
|
**Run:**
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
@@ -390,10 +466,10 @@ docker run -d \
|
|||||||
docker run -d -p 8080:8080 -p 50051:50051 notifier:latest
|
docker run -d -p 8080:8080 -p 50051:50051 notifier:latest
|
||||||
|
|
||||||
# REST only
|
# REST only
|
||||||
docker run -d -p 8080:8080 -e SERVER_MODE=rest notifier:latest
|
docker run -d -p 8080:8080 -e NOTIFIER_SERVER_MODE=rest notifier:latest
|
||||||
|
|
||||||
# gRPC only
|
# gRPC only
|
||||||
docker run -d -p 50051:50051 -e SERVER_MODE=grpc notifier:latest
|
docker run -d -p 50051:50051 -e NOTIFIER_SERVER_MODE=grpc notifier:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Compose:**
|
**Docker Compose:**
|
||||||
@@ -485,17 +561,20 @@ notifier/
|
|||||||
│ │ └── notifier.proto # gRPC service definition
|
│ │ └── notifier.proto # gRPC service definition
|
||||||
│ └── rest/
|
│ └── rest/
|
||||||
│ ├── handlers.go # HTTP handlers
|
│ ├── handlers.go # HTTP handlers
|
||||||
|
│ ├── keys.go # API key management handlers
|
||||||
│ ├── router.go # Route configuration
|
│ ├── router.go # Route configuration
|
||||||
│ └── types.go # Request/response types
|
│ └── types.go # Request/response types
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── server/main.go # Unified server (configurable mode)
|
│ └── server/main.go # Unified server (configurable mode)
|
||||||
├── internal/
|
├── internal/
|
||||||
|
│ ├── auth/ # API key auth, RBAC, key store, bootstrap
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
│ │ └── config.go # Configuration management
|
│ │ └── config.go # Configuration management
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
│ │ ├── notification.go # Core types
|
│ │ ├── notification.go # Core types
|
||||||
│ │ ├── notifier.go # Notifier interface
|
│ │ ├── notifier.go # Notifier interface
|
||||||
│ │ └── queue.go # Queue interface
|
│ │ └── queue.go # Queue interface
|
||||||
|
│ ├── logging/ # Structured logger
|
||||||
│ ├── notifier/
|
│ ├── notifier/
|
||||||
│ │ ├── notifier.go # Factory & base
|
│ │ ├── notifier.go # Factory & base
|
||||||
│ │ ├── smtp.go # Email notifier
|
│ │ ├── smtp.go # Email notifier
|
||||||
@@ -513,8 +592,7 @@ notifier/
|
|||||||
│ ├── ingress.yaml
|
│ ├── ingress.yaml
|
||||||
│ ├── hpa.yaml
|
│ ├── hpa.yaml
|
||||||
│ └── kustomization.yaml
|
│ └── kustomization.yaml
|
||||||
├── docs/
|
├── docs/ # Guides & references (see docs/INDEX.md)
|
||||||
│ └── NTFY_GUIDE.md # Ntfy integration guide
|
|
||||||
├── config.yaml # Default configuration
|
├── config.yaml # Default configuration
|
||||||
├── docker-compose.yaml
|
├── docker-compose.yaml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
@@ -541,7 +619,7 @@ make lint # Run golangci-lint
|
|||||||
make check # Run fmt-check + vet + mod verify
|
make check # Run fmt-check + vet + mod verify
|
||||||
make qa # Run all quality checks
|
make qa # Run all quality checks
|
||||||
make proto-gen # Generate protobuf code
|
make proto-gen # Generate protobuf code
|
||||||
make docker-build # Build Docker image
|
make docker-build # Build Docker image (multi-arch + push when REGISTRY is set)
|
||||||
make clean # Clean build artifacts
|
make clean # Clean build artifacts
|
||||||
make help # Show all available targets
|
make help # Show all available targets
|
||||||
```
|
```
|
||||||
@@ -573,9 +651,17 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide.
|
|||||||
|
|
||||||
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
|
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
|
||||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details
|
||||||
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration guide
|
- [docs/INDEX.md](docs/INDEX.md) - Full documentation index
|
||||||
- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition
|
- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition
|
||||||
|
|
||||||
|
**Guides:**
|
||||||
|
- [docs/AUTH.md](docs/AUTH.md) / [docs/AUTH_QUICK_START.md](docs/AUTH_QUICK_START.md) - Authentication & RBAC
|
||||||
|
- [docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) - API key lifecycle
|
||||||
|
- [docs/EMAIL_GUIDE.md](docs/EMAIL_GUIDE.md) - Email/SMTP setup
|
||||||
|
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration
|
||||||
|
- [docs/TLS_SECURITY.md](docs/TLS_SECURITY.md) - TLS configuration
|
||||||
|
- [docs/CORS_KUBERNETES_GUIDE.md](docs/CORS_KUBERNETES_GUIDE.md) - CORS in Kubernetes
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -589,7 +675,9 @@ go test -cover ./...
|
|||||||
go test ./internal/notifier/...
|
go test ./internal/notifier/...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Current Status:** Core implementation complete, tests pending.
|
**Current Status:** Core implementation complete with unit tests across the
|
||||||
|
config, service, notifier, and auth packages. Run `make test` (race detector
|
||||||
|
enabled) or `make test-coverage` for an HTML report.
|
||||||
|
|
||||||
## Monitoring & Operations
|
## Monitoring & Operations
|
||||||
|
|
||||||
@@ -647,27 +735,29 @@ The server handles `SIGINT` and `SIGTERM` gracefully:
|
|||||||
### Completed ✅
|
### Completed ✅
|
||||||
- [x] Core notification system
|
- [x] Core notification system
|
||||||
- [x] REST API (fully functional)
|
- [x] REST API (fully functional)
|
||||||
- [x] gRPC API (protobuf defined)
|
- [x] gRPC API (fully functional, with reflection)
|
||||||
- [x] Local queue with workers
|
- [x] Local queue with workers
|
||||||
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
|
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
|
||||||
- [x] Priority and retry logic
|
- [x] Priority and retry logic
|
||||||
- [x] Batch operations
|
- [x] Batch operations
|
||||||
|
- [x] API key authentication with RBAC and runtime key management
|
||||||
|
- [x] CORS support
|
||||||
|
- [x] Notification retention/cleanup
|
||||||
- [x] Docker support
|
- [x] Docker support
|
||||||
- [x] Kubernetes manifests with HPA
|
- [x] Kubernetes manifests with HPA
|
||||||
- [x] Health checks and stats
|
- [x] Health checks and stats
|
||||||
- [x] Configuration management
|
- [x] Configuration management
|
||||||
|
- [x] Unit test suite (race detector)
|
||||||
|
|
||||||
### Planned 🚧
|
### Planned 🚧
|
||||||
- [ ] gRPC handler implementation
|
|
||||||
- [ ] Kafka queue adapter
|
- [ ] Kafka queue adapter
|
||||||
- [ ] Database persistence (PostgreSQL)
|
- [ ] Database-backed notification persistence (PostgreSQL)
|
||||||
- [ ] Notification templates
|
- [ ] Notification templates
|
||||||
- [ ] Webhook callbacks
|
- [ ] Webhook callbacks
|
||||||
- [ ] Authentication/Authorization (API keys, OAuth)
|
- [ ] OAuth authentication
|
||||||
- [ ] Rate limiting (per client, per notifier)
|
- [ ] Per-notifier rate limiting
|
||||||
- [ ] Prometheus metrics
|
- [ ] Prometheus metrics endpoint
|
||||||
- [ ] OpenTelemetry tracing
|
- [ ] OpenTelemetry tracing
|
||||||
- [ ] Comprehensive test suite
|
|
||||||
- [ ] Circuit breakers for notifiers
|
- [ ] Circuit breakers for notifiers
|
||||||
- [ ] Dead letter queue
|
- [ ] Dead letter queue
|
||||||
- [ ] Admin dashboard
|
- [ ] Admin dashboard
|
||||||
|
|||||||
+8
-9
@@ -8,6 +8,8 @@ import (
|
|||||||
pb "github.com/igodwin/notifier/api/grpc/pb"
|
pb "github.com/igodwin/notifier/api/grpc/pb"
|
||||||
"github.com/igodwin/notifier/internal/domain"
|
"github.com/igodwin/notifier/internal/domain"
|
||||||
"github.com/igodwin/notifier/internal/logging"
|
"github.com/igodwin/notifier/internal/logging"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
|
|||||||
Priority: domain.Priority(req.Priority),
|
Priority: domain.Priority(req.Priority),
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
|
HTMLBody: req.HtmlBody,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Recipients: req.Recipients,
|
Recipients: req.Recipients,
|
||||||
CC: req.Cc,
|
CC: req.Cc,
|
||||||
@@ -82,12 +85,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
|
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
|
||||||
req.Type, req.Account, err)
|
req.Type, req.Account, err)
|
||||||
return &pb.SendNotificationResponse{
|
return nil, status.Errorf(codes.Internal, "failed to send notification: %v", err)
|
||||||
Result: &pb.NotificationResult{
|
|
||||||
Success: false,
|
|
||||||
Error: err.Error(),
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log success
|
// Log success
|
||||||
@@ -139,7 +137,7 @@ func (h *NotifierHandler) SendBatchNotifications(ctx context.Context, req *pb.Se
|
|||||||
func (h *NotifierHandler) GetNotification(ctx context.Context, req *pb.GetNotificationRequest) (*pb.GetNotificationResponse, error) {
|
func (h *NotifierHandler) GetNotification(ctx context.Context, req *pb.GetNotificationRequest) (*pb.GetNotificationResponse, error) {
|
||||||
notification, err := h.service.GetNotification(ctx, req.Id)
|
notification, err := h.service.GetNotification(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, status.Errorf(codes.NotFound, "notification not found: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.GetNotificationResponse{
|
return &pb.GetNotificationResponse{
|
||||||
@@ -154,7 +152,7 @@ func (h *NotifierHandler) ListNotifications(ctx context.Context, req *pb.ListNot
|
|||||||
|
|
||||||
notifications, err := h.service.ListNotifications(ctx, filter)
|
notifications, err := h.service.ListNotifications(ctx, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, status.Errorf(codes.Internal, "failed to list notifications: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
protoNotifications := make([]*pb.Notification, len(notifications))
|
protoNotifications := make([]*pb.Notification, len(notifications))
|
||||||
@@ -230,7 +228,7 @@ func (h *NotifierHandler) GetNotifiers(ctx context.Context, req *pb.GetNotifiers
|
|||||||
notifiers, err := h.service.GetNotifiers(ctx)
|
notifiers, err := h.service.GetNotifiers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err)
|
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err)
|
||||||
return nil, err
|
return nil, status.Errorf(codes.Internal, "failed to get notifiers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert domain notifiers to proto notifiers
|
// Convert domain notifiers to proto notifiers
|
||||||
@@ -371,6 +369,7 @@ func convertDomainToProtoNotification(notif *domain.Notification) *pb.Notificati
|
|||||||
Status: convertDomainToProtoStatus(notif.Status),
|
Status: convertDomainToProtoStatus(notif.Status),
|
||||||
Subject: notif.Subject,
|
Subject: notif.Subject,
|
||||||
Body: notif.Body,
|
Body: notif.Body,
|
||||||
|
HtmlBody: notif.HTMLBody,
|
||||||
Recipients: notif.Recipients,
|
Recipients: notif.Recipients,
|
||||||
Metadata: convertInterfaceMapToString(notif.Metadata),
|
Metadata: convertInterfaceMapToString(notif.Metadata),
|
||||||
CreatedAt: timestamppb.New(notif.CreatedAt),
|
CreatedAt: timestamppb.New(notif.CreatedAt),
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ message Notification {
|
|||||||
NotificationStatus status = 5;
|
NotificationStatus status = 5;
|
||||||
string subject = 6;
|
string subject = 6;
|
||||||
string body = 7;
|
string body = 7;
|
||||||
ContentType content_type = 18; // Format of the body (text or html)
|
ContentType content_type = 18 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html).
|
||||||
|
string html_body = 19; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
|
||||||
repeated string recipients = 8;
|
repeated string recipients = 8;
|
||||||
repeated string cc = 16; // Carbon copy recipients (email only)
|
repeated string cc = 16; // Carbon copy recipients (email only)
|
||||||
repeated string bcc = 17; // Blind carbon copy recipients (email only)
|
repeated string bcc = 17; // Blind carbon copy recipients (email only)
|
||||||
@@ -111,13 +112,14 @@ message SendNotificationRequest {
|
|||||||
Priority priority = 3;
|
Priority priority = 3;
|
||||||
string subject = 4;
|
string subject = 4;
|
||||||
string body = 5;
|
string body = 5;
|
||||||
ContentType content_type = 12; // Format of the body (text or html) - auto-detected if not specified
|
ContentType content_type = 12 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html) - auto-detected if not specified.
|
||||||
repeated string recipients = 6;
|
repeated string recipients = 6;
|
||||||
repeated string cc = 10; // Carbon copy recipients (email only)
|
repeated string cc = 10; // Carbon copy recipients (email only)
|
||||||
repeated string bcc = 11; // Blind carbon copy recipients (email only)
|
repeated string bcc = 11; // Blind carbon copy recipients (email only)
|
||||||
map<string, string> metadata = 7;
|
map<string, string> metadata = 7;
|
||||||
google.protobuf.Timestamp scheduled_for = 8;
|
google.protobuf.Timestamp scheduled_for = 8;
|
||||||
int32 max_retries = 9;
|
int32 max_retries = 9;
|
||||||
|
string html_body = 13; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendNotificationResponse returns the result of sending a notification
|
// SendNotificationResponse returns the result of sending a notification
|
||||||
|
|||||||
+19
-17
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/igodwin/notifier/internal/auth"
|
"github.com/igodwin/notifier/internal/auth"
|
||||||
"github.com/igodwin/notifier/internal/logging"
|
"github.com/igodwin/notifier/internal/logging"
|
||||||
)
|
)
|
||||||
@@ -76,7 +77,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Check authorization - must have admin role
|
// Check authorization - must have admin role
|
||||||
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
if !ok || !h.hasRole(authCtx, "admin") {
|
if !ok || !h.hasRole(authCtx, "admin") {
|
||||||
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
||||||
return
|
return
|
||||||
@@ -143,7 +144,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
|
|||||||
func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
|
||||||
return
|
return
|
||||||
@@ -193,24 +194,25 @@ type RevokeKeyRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RevokeKey deactivates an API key
|
// RevokeKey deactivates an API key
|
||||||
// DELETE /api/v1/admin/keys/:key
|
// DELETE /api/v1/admin/keys/:name
|
||||||
// Requires: admin role
|
// Requires: admin role
|
||||||
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
|
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
if !ok || !h.hasRole(authCtx, "admin") {
|
if !ok || !h.hasRole(authCtx, "admin") {
|
||||||
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract key from path parameter
|
// Extract key name from path parameter (not the raw key, to avoid leaking secrets in URLs)
|
||||||
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
|
vars := mux.Vars(r)
|
||||||
|
keyName := vars["name"]
|
||||||
|
|
||||||
var req RevokeKeyRequest
|
var req RevokeKeyRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional
|
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional
|
||||||
|
|
||||||
err := h.keyStore.DeactivateKey(ctx, keyStr, authCtx.ClientID)
|
err := h.keyStore.DeactivateKeyByName(ctx, keyName, authCtx.ClientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
h.respondError(w, http.StatusNotFound, "Key not found", "")
|
h.respondError(w, http.StatusNotFound, "Key not found", "")
|
||||||
@@ -231,19 +233,19 @@ type RotateKeyRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RotateKey creates a new API key to replace the old one
|
// RotateKey creates a new API key to replace the old one
|
||||||
// POST /api/v1/admin/keys/:key/rotate
|
// POST /api/v1/admin/keys/:name/rotate
|
||||||
// Requires: admin role
|
// Requires: admin role
|
||||||
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
|
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
if !ok || !h.hasRole(authCtx, "admin") {
|
if !ok || !h.hasRole(authCtx, "admin") {
|
||||||
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
oldKeyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
|
vars := mux.Vars(r)
|
||||||
oldKeyStr = strings.TrimSuffix(oldKeyStr, "/rotate")
|
_ = vars["name"] // Key name from URL (rotation not yet implemented)
|
||||||
|
|
||||||
var req RotateKeyRequest
|
var req RotateKeyRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
@@ -265,19 +267,19 @@ type GetAuditLogResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAuditLog retrieves the audit log for a key
|
// GetAuditLog retrieves the audit log for a key
|
||||||
// GET /api/v1/admin/keys/:key/audit
|
// GET /api/v1/admin/keys/:name/audit
|
||||||
// Requires: admin role
|
// Requires: admin role
|
||||||
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
if !ok || !h.hasRole(authCtx, "admin") {
|
if !ok || !h.hasRole(authCtx, "admin") {
|
||||||
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
|
vars := mux.Vars(r)
|
||||||
keyStr = strings.TrimSuffix(keyStr, "/audit")
|
keyName := vars["name"]
|
||||||
|
|
||||||
limit := 100
|
limit := 100
|
||||||
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||||
@@ -286,7 +288,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logs, err := h.keyStore.GetAuditLog(ctx, keyStr, limit)
|
logs, err := h.keyStore.GetAuditLogByName(ctx, keyName, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Errorf("Failed to get audit log: %v", err)
|
h.logger.Errorf("Failed to get audit log: %v", err)
|
||||||
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error())
|
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error())
|
||||||
@@ -294,7 +296,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := GetAuditLogResponse{
|
resp := GetAuditLogResponse{
|
||||||
Key: "nk_" + keyStr[len(keyStr)-4:],
|
Key: keyName,
|
||||||
AuditLog: logs,
|
AuditLog: logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -86,20 +86,33 @@ func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *lo
|
|||||||
keyHandler := NewKeyManagementHandler(keyStore, logger)
|
keyHandler := NewKeyManagementHandler(keyStore, logger)
|
||||||
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
|
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
|
||||||
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
|
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
|
||||||
v1.HandleFunc("/admin/keys/{key}", keyHandler.RevokeKey).Methods(http.MethodDelete)
|
v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete)
|
||||||
v1.HandleFunc("/admin/keys/{key}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
|
v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
|
||||||
v1.HandleFunc("/admin/keys/{key}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
|
v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check route (no auth required)
|
// Health check route (no auth required)
|
||||||
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
|
||||||
|
|
||||||
// Middleware - logging and CORS
|
// Middleware - logging, request size limit, and CORS
|
||||||
router.Use(loggingMiddleware)
|
router.Use(loggingMiddleware)
|
||||||
|
v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxBodySizeMiddleware limits the size of incoming request bodies to prevent DoS.
|
||||||
|
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Body != nil {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// loggingMiddleware logs incoming requests
|
// loggingMiddleware logs incoming requests
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+17
-3
@@ -2,6 +2,7 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -15,7 +16,8 @@ type SendNotificationRequest struct {
|
|||||||
Priority int `json:"priority,omitempty"`
|
Priority int `json:"priority,omitempty"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
ContentType string `json:"content_type,omitempty"` // "text" or "html" - auto-detected if not specified
|
HTMLBody string `json:"html_body,omitempty"` // Optional HTML body for email; if set, sends multipart/alternative.
|
||||||
|
ContentType string `json:"content_type,omitempty"` // Deprecated: prefer html_body. "text" or "html".
|
||||||
Recipients []string `json:"recipients"`
|
Recipients []string `json:"recipients"`
|
||||||
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
|
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
|
||||||
BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only)
|
BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only)
|
||||||
@@ -41,6 +43,14 @@ func (r *SendNotificationRequest) Validate() error {
|
|||||||
return fmt.Errorf("body is required")
|
return fmt.Errorf("body is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate content type if specified (must be "text" or "html", case-insensitive)
|
||||||
|
if r.ContentType != "" {
|
||||||
|
contentTypeLower := strings.ToLower(r.ContentType)
|
||||||
|
if contentTypeLower != "text" && contentTypeLower != "html" {
|
||||||
|
return fmt.Errorf("invalid content_type: must be 'text' or 'html' (got %q)", r.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +62,9 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert content type, defaulting to text
|
// Convert content type, defaulting to text
|
||||||
contentType := domain.ContentType(r.ContentType)
|
// Normalize to lowercase to handle case-insensitive input (e.g., "HTML" -> "html")
|
||||||
if contentType == "" {
|
contentType := domain.ContentType(strings.ToLower(r.ContentType))
|
||||||
|
if contentType == "" || contentType != domain.ContentTypeHTML {
|
||||||
contentType = domain.ContentTypeText
|
contentType = domain.ContentTypeText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +76,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
|
|||||||
Status: domain.StatusPending,
|
Status: domain.StatusPending,
|
||||||
Subject: r.Subject,
|
Subject: r.Subject,
|
||||||
Body: r.Body,
|
Body: r.Body,
|
||||||
|
HTMLBody: r.HTMLBody,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Recipients: r.Recipients,
|
Recipients: r.Recipients,
|
||||||
CC: r.CC,
|
CC: r.CC,
|
||||||
@@ -101,6 +113,7 @@ type Notification struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
HTMLBody string `json:"html_body,omitempty"`
|
||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
Recipients []string `json:"recipients"`
|
Recipients []string `json:"recipients"`
|
||||||
CC []string `json:"cc,omitempty"`
|
CC []string `json:"cc,omitempty"`
|
||||||
@@ -124,6 +137,7 @@ func NotificationFromDomain(n *domain.Notification) Notification {
|
|||||||
Status: string(n.Status),
|
Status: string(n.Status),
|
||||||
Subject: n.Subject,
|
Subject: n.Subject,
|
||||||
Body: n.Body,
|
Body: n.Body,
|
||||||
|
HTMLBody: n.HTMLBody,
|
||||||
ContentType: string(n.ContentType),
|
ContentType: string(n.ContentType),
|
||||||
Recipients: n.Recipients,
|
Recipients: n.Recipients,
|
||||||
CC: n.CC,
|
CC: n.CC,
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Failed to create database key store: %v", err)
|
logger.Fatalf("Failed to create database key store: %v", err)
|
||||||
}
|
}
|
||||||
logger.Infof("Connected to authentication database: %s", cfg.Auth.Database.URL)
|
logger.Infof("Connected to authentication database: %s", config.SanitizeDatabaseURL(cfg.Auth.Database.URL))
|
||||||
} else {
|
} else {
|
||||||
logger.Warn("No database configured for authentication - API keys will only be stored in memory")
|
logger.Warn("No database configured for authentication - API keys will only be stored in memory")
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-5
@@ -115,24 +115,29 @@ func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
|
|||||||
|
|
||||||
// CheckRateLimit checks if a key has exceeded its rate limit
|
// CheckRateLimit checks if a key has exceeded its rate limit
|
||||||
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
||||||
s.mu.Lock()
|
// Look up key and limiter under the store lock, then release it
|
||||||
defer s.mu.Unlock()
|
// before acquiring the per-key limiter lock to avoid nested locking.
|
||||||
|
s.mu.RLock()
|
||||||
key, exists := s.keys[keyStr]
|
key, exists := s.keys[keyStr]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
s.mu.RUnlock()
|
||||||
return false, fmt.Errorf("invalid API key")
|
return false, fmt.Errorf("invalid API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlimited rate limit
|
// Unlimited rate limit
|
||||||
if key.RateLimit <= 0 {
|
if key.RateLimit <= 0 {
|
||||||
|
s.mu.RUnlock()
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
limiter, exists := s.rateLimits[keyStr]
|
limiter, exists := s.rateLimits[keyStr]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
s.mu.RUnlock()
|
||||||
return false, fmt.Errorf("rate limiter not found")
|
return false, fmt.Errorf("rate limiter not found")
|
||||||
}
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Now lock only the per-key rate limiter
|
||||||
limiter.mu.Lock()
|
limiter.mu.Lock()
|
||||||
defer limiter.mu.Unlock()
|
defer limiter.mu.Unlock()
|
||||||
|
|
||||||
@@ -206,13 +211,16 @@ func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authContextKey is an unexported type for context keys to avoid collisions.
|
||||||
|
type authContextKey struct{}
|
||||||
|
|
||||||
// ContextWithAuth adds auth context to a request context
|
// ContextWithAuth adds auth context to a request context
|
||||||
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
|
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
|
||||||
return context.WithValue(ctx, "auth", auth)
|
return context.WithValue(ctx, authContextKey{}, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthContext retrieves auth context from a request context
|
// GetAuthContext retrieves auth context from a request context
|
||||||
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
|
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
|
||||||
auth, ok := ctx.Value("auth").(*AuthContext)
|
auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
|
||||||
return auth, ok
|
return auth, ok
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -34,21 +34,32 @@ func (a *NotifierAuthz) IsAuthorized(auth *AuthContext, notificationType domain.
|
|||||||
key := makeAuthzKey(notificationType, account)
|
key := makeAuthzKey(notificationType, account)
|
||||||
allowedRoles, exists := a.rules[key]
|
allowedRoles, exists := a.rules[key]
|
||||||
|
|
||||||
// If no specific rule is registered, allow all authenticated users
|
// If RBAC is enabled (at least one rule exists), restrict access:
|
||||||
if !exists {
|
// - Notifiers with explicit rules: check if user has allowed roles
|
||||||
return true
|
// - Notifiers without rules: deny access (must be explicitly allowed)
|
||||||
}
|
if a.HasRules() {
|
||||||
|
if !exists {
|
||||||
// Check if any of the user's roles is in the allowed roles
|
// RBAC is enabled but this notifier has no rule - deny access
|
||||||
for _, userRole := range auth.Roles {
|
return false
|
||||||
for _, allowedRole := range allowedRoles {
|
}
|
||||||
if userRole == allowedRole {
|
// Check if any of the user's roles is in the allowed roles
|
||||||
return true
|
for _, userRole := range auth.Roles {
|
||||||
|
for _, allowedRole := range allowedRoles {
|
||||||
|
if userRole == allowedRole {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
// If no rules are registered at all, allow all authenticated users (open access)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRules returns true if any authorization rules have been registered
|
||||||
|
func (a *NotifierAuthz) HasRules() bool {
|
||||||
|
return len(a.rules) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllowedRoles returns the allowed roles for a notifier
|
// GetAllowedRoles returns the allowed roles for a notifier
|
||||||
|
|||||||
@@ -347,6 +347,89 @@ func (ks *KeyStoreDB) GetAuditLog(ctx context.Context, keyStr string, limit int)
|
|||||||
return logs, rows.Err()
|
return logs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetKeyByName retrieves an API key by its name
|
||||||
|
func (ks *KeyStoreDB) GetKeyByName(ctx context.Context, name string) (*APIKey, error) {
|
||||||
|
query := `
|
||||||
|
SELECT key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit
|
||||||
|
FROM api_keys
|
||||||
|
WHERE name = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var key APIKey
|
||||||
|
var roles []string
|
||||||
|
|
||||||
|
err := ks.db.QueryRowContext(ctx, query, name).Scan(
|
||||||
|
&key.Key,
|
||||||
|
&key.Name,
|
||||||
|
&key.ClientID,
|
||||||
|
pq.Array(&roles),
|
||||||
|
&key.CreatedAt,
|
||||||
|
&key.LastUsedAt,
|
||||||
|
&key.ExpiresAt,
|
||||||
|
&key.IsActive,
|
||||||
|
&key.RateLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get key by name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key.Roles = roles
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateKeyByName disables an API key by its name
|
||||||
|
func (ks *KeyStoreDB) DeactivateKeyByName(ctx context.Context, name string, deactivatedBy string) error {
|
||||||
|
// First get the key to find its raw key for cache invalidation and audit
|
||||||
|
key, err := ks.GetKeyByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ks.DeactivateKey(ctx, key.Key, deactivatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogByName retrieves audit log entries for a key identified by name
|
||||||
|
func (ks *KeyStoreDB) GetAuditLogByName(ctx context.Context, name string, limit int) ([]map[string]interface{}, error) {
|
||||||
|
query := `
|
||||||
|
SELECT al.action, al.performed_by, al.performed_at, al.details
|
||||||
|
FROM api_key_audit_log al
|
||||||
|
JOIN api_keys ak ON al.key_id = ak.id
|
||||||
|
WHERE ak.name = $1
|
||||||
|
ORDER BY al.performed_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := ks.db.QueryContext(ctx, query, name, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get audit log: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var action, performedBy, details string
|
||||||
|
var performedAt time.Time
|
||||||
|
|
||||||
|
err := rows.Scan(&action, &performedBy, &performedAt, &details)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logs = append(logs, map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"performed_by": performedBy,
|
||||||
|
"performed_at": performedAt,
|
||||||
|
"details": details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Custom errors
|
// Custom errors
|
||||||
var (
|
var (
|
||||||
ErrKeyNotFound = fmt.Errorf("API key not found")
|
ErrKeyNotFound = fmt.Errorf("API key not found")
|
||||||
|
|||||||
@@ -136,6 +136,32 @@ func (h *HybridKeyStore) GetAuditLog(ctx context.Context, keyStr string, limit i
|
|||||||
return h.db.GetAuditLog(ctx, keyStr, limit)
|
return h.db.GetAuditLog(ctx, keyStr, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeactivateKeyByName deactivates a key by its name (avoids exposing raw key in URLs)
|
||||||
|
func (h *HybridKeyStore) DeactivateKeyByName(ctx context.Context, name string, deactivatedBy string) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
// Look up the key by name in DB to get the raw key for cache invalidation
|
||||||
|
key, err := h.db.GetKeyByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from cache
|
||||||
|
h.cache.mu.Lock()
|
||||||
|
delete(h.cache.keys, key.Key)
|
||||||
|
delete(h.cache.rateLimits, key.Key)
|
||||||
|
h.cache.mu.Unlock()
|
||||||
|
|
||||||
|
// Deactivate in database
|
||||||
|
return h.db.DeactivateKey(ctx, key.Key, deactivatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogByName retrieves audit log for a key identified by name
|
||||||
|
func (h *HybridKeyStore) GetAuditLogByName(ctx context.Context, name string, limit int) ([]map[string]interface{}, error) {
|
||||||
|
return h.db.GetAuditLogByName(ctx, name, limit)
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (h *HybridKeyStore) Close() error {
|
func (h *HybridKeyStore) Close() error {
|
||||||
return h.db.Close()
|
return h.db.Close()
|
||||||
|
|||||||
@@ -418,6 +418,9 @@ func (c *Config) Sanitize() map[string]interface{} {
|
|||||||
// Sanitize auth config
|
// Sanitize auth config
|
||||||
sanitized["auth"] = map[string]interface{}{
|
sanitized["auth"] = map[string]interface{}{
|
||||||
"enabled": c.Auth.Enabled,
|
"enabled": c.Auth.Enabled,
|
||||||
|
"database": map[string]interface{}{
|
||||||
|
"url": SanitizeDatabaseURL(c.Auth.Database.URL),
|
||||||
|
},
|
||||||
"bootstrap": map[string]interface{}{
|
"bootstrap": map[string]interface{}{
|
||||||
"enabled": c.Auth.Bootstrap.Enabled,
|
"enabled": c.Auth.Bootstrap.Enabled,
|
||||||
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
|
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
|
||||||
@@ -438,6 +441,47 @@ func (c *Config) Sanitize() map[string]interface{} {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SanitizeDatabaseURL redacts the password from a database connection URL
|
||||||
|
// Handles formats like: postgresql://user:password@host:port/database
|
||||||
|
// Also handles passwords containing @ characters by finding the last @
|
||||||
|
func SanitizeDatabaseURL(dbURL string) string {
|
||||||
|
if dbURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the protocol (e.g., "postgresql://", "mysql://")
|
||||||
|
protocolIdx := strings.Index(dbURL, "://")
|
||||||
|
if protocolIdx == -1 {
|
||||||
|
return dbURL
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := dbURL[:protocolIdx+3]
|
||||||
|
remaining := dbURL[protocolIdx+3:]
|
||||||
|
|
||||||
|
// Find the LAST @ symbol that separates credentials from host
|
||||||
|
// (to handle passwords that may contain @ characters)
|
||||||
|
atIdx := strings.LastIndex(remaining, "@")
|
||||||
|
if atIdx == -1 {
|
||||||
|
// No credentials in the URL
|
||||||
|
return dbURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract credentials part and check if there's a password
|
||||||
|
credentials := remaining[:atIdx]
|
||||||
|
hostPart := remaining[atIdx:]
|
||||||
|
|
||||||
|
// Check if there's a colon (indicating a password)
|
||||||
|
colonIdx := strings.Index(credentials, ":")
|
||||||
|
if colonIdx == -1 {
|
||||||
|
// No password, just username
|
||||||
|
return protocol + credentials + hostPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract username and redact password
|
||||||
|
username := credentials[:colonIdx]
|
||||||
|
return protocol + username + ":***REDACTED***" + hostPart
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set
|
// GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set
|
||||||
func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string {
|
func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string {
|
||||||
switch notifierType {
|
switch notifierType {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeDatabaseURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "PostgreSQL with password",
|
||||||
|
input: "postgresql://user:password@localhost:5432/dbname",
|
||||||
|
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PostgreSQL without password",
|
||||||
|
input: "postgresql://user@localhost:5432/dbname",
|
||||||
|
expected: "postgresql://user@localhost:5432/dbname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MySQL with special characters in password",
|
||||||
|
input: "mysql://root:SuperSecret123!@db.example.com:3306/mydb",
|
||||||
|
expected: "mysql://root:***REDACTED***@db.example.com:3306/mydb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty URL",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid URL without protocol",
|
||||||
|
input: "invalid-url",
|
||||||
|
expected: "invalid-url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL without credentials",
|
||||||
|
input: "postgresql://localhost:5432/dbname",
|
||||||
|
expected: "postgresql://localhost:5432/dbname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PostgreSQL with password containing colons",
|
||||||
|
input: "postgresql://user:pass:word@localhost:5432/dbname",
|
||||||
|
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PostgreSQL with complex hostname and port",
|
||||||
|
input: "postgresql://admin:p@ssw0rd!@db-prod.example.com:5432/production",
|
||||||
|
expected: "postgresql://admin:***REDACTED***@db-prod.example.com:5432/production",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := SanitizeDatabaseURL(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("SanitizeDatabaseURL(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,13 @@ type Notification struct {
|
|||||||
// Body is the main content of the notification
|
// Body is the main content of the notification
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
|
||||||
// ContentType specifies the format of the body (text or html)
|
// HTMLBody is an optional HTML body for email notifications. If non-empty, the email is
|
||||||
// Defaults to "text" if not specified. HTML is auto-detected if body starts with < or contains HTML tags.
|
// sent as multipart/alternative with Body as text/plain and HTMLBody as text/html.
|
||||||
|
// Ignored for non-email notification types.
|
||||||
|
HTMLBody string `json:"html_body,omitempty"`
|
||||||
|
|
||||||
|
// ContentType specifies the format of the body (text or html).
|
||||||
|
// Deprecated: prefer setting HTMLBody alongside a plain-text Body.
|
||||||
ContentType ContentType `json:"content_type,omitempty"`
|
ContentType ContentType `json:"content_type,omitempty"`
|
||||||
|
|
||||||
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
|
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
|
||||||
|
|||||||
@@ -181,6 +181,20 @@ func createNtfyHTTPClient(config *NtfyConfig) (*http.Client, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate overrides BaseNotifier.Validate to allow 0 recipients when a DefaultTopic is configured.
|
||||||
|
func (n *NtfyNotifier) Validate(notification *domain.Notification) error {
|
||||||
|
if notification == nil {
|
||||||
|
return fmt.Errorf("notification is nil")
|
||||||
|
}
|
||||||
|
if notification.Type != domain.TypeNtfy {
|
||||||
|
return fmt.Errorf("notification type mismatch: expected %s, got %s", domain.TypeNtfy, notification.Type)
|
||||||
|
}
|
||||||
|
if len(notification.Recipients) == 0 && n.config.DefaultTopic == "" {
|
||||||
|
return fmt.Errorf("notification has no recipients and no default topic is configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Send sends a notification via ntfy
|
// Send sends a notification via ntfy
|
||||||
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||||
if err := ValidateContext(ctx); err != nil {
|
if err := ValidateContext(ctx); err != nil {
|
||||||
|
|||||||
+24
-20
@@ -145,18 +145,15 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
|
|||||||
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
|
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
|
||||||
builder.WriteString("MIME-Version: 1.0\r\n")
|
builder.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
|
||||||
// Auto-detect HTML if content type not set
|
switch {
|
||||||
contentType := notification.ContentType
|
case notification.HTMLBody != "":
|
||||||
if contentType == "" {
|
// Caller provided distinct plain-text and HTML versions: send multipart/alternative
|
||||||
contentType = detectContentType(notification.Body)
|
// using Body verbatim as text/plain and HTMLBody as text/html (no auto-strip).
|
||||||
}
|
s.buildMultipartMessage(&builder, notification.Body, notification.HTMLBody)
|
||||||
|
case isHTMLContent(notification):
|
||||||
// Build message based on content type
|
// Legacy path (deprecated): Body itself is HTML. Auto-derive a plain-text fallback.
|
||||||
if contentType == domain.ContentTypeHTML {
|
s.buildMultipartMessage(&builder, htmlToPlainText(notification.Body), notification.Body)
|
||||||
// Send multipart/alternative with both text and HTML
|
default:
|
||||||
s.buildMultipartMessage(&builder, notification)
|
|
||||||
} else {
|
|
||||||
// Send plain text only
|
|
||||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
builder.WriteString("\r\n")
|
builder.WriteString("\r\n")
|
||||||
builder.WriteString(notification.Body)
|
builder.WriteString(notification.Body)
|
||||||
@@ -165,31 +162,38 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
|
|||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMultipartMessage builds a multipart/alternative email with both text and HTML versions
|
// isHTMLContent reports whether the notification's Body should be treated as HTML
|
||||||
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, notification *domain.Notification) {
|
// under the legacy content_type path.
|
||||||
// Generate a unique boundary
|
func isHTMLContent(notification *domain.Notification) bool {
|
||||||
|
contentType := notification.ContentType
|
||||||
|
if contentType == "" || contentType == "auto" {
|
||||||
|
contentType = detectContentType(notification.Body)
|
||||||
|
}
|
||||||
|
return contentType == domain.ContentTypeHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMultipartMessage builds a multipart/alternative email with the given plain-text
|
||||||
|
// and HTML parts.
|
||||||
|
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, plainText, htmlBody string) {
|
||||||
boundary := generateBoundary()
|
boundary := generateBoundary()
|
||||||
|
|
||||||
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||||
builder.WriteString("\r\n")
|
builder.WriteString("\r\n")
|
||||||
|
|
||||||
// Plain text version (auto-generated from HTML)
|
|
||||||
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||||
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
||||||
builder.WriteString("\r\n")
|
builder.WriteString("\r\n")
|
||||||
builder.WriteString(htmlToPlainText(notification.Body))
|
builder.WriteString(plainText)
|
||||||
builder.WriteString("\r\n\r\n")
|
builder.WriteString("\r\n\r\n")
|
||||||
|
|
||||||
// HTML version
|
|
||||||
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||||
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
|
||||||
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
|
||||||
builder.WriteString("\r\n")
|
builder.WriteString("\r\n")
|
||||||
builder.WriteString(notification.Body)
|
builder.WriteString(htmlBody)
|
||||||
builder.WriteString("\r\n\r\n")
|
builder.WriteString("\r\n\r\n")
|
||||||
|
|
||||||
// End boundary
|
|
||||||
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,9 +243,11 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
|
|||||||
|
|
||||||
// Send the notification
|
// Send the notification
|
||||||
result, err := notifier.Send(ctx, notification)
|
result, err := notifier.Send(ctx, notification)
|
||||||
if err != nil || !result.Success {
|
if err != nil || result == nil || !result.Success {
|
||||||
notification.RetryCount++
|
notification.RetryCount++
|
||||||
notification.LastError = result.Error
|
if result != nil {
|
||||||
|
notification.LastError = result.Error
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notification.LastError = err.Error()
|
notification.LastError = err.Error()
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,16 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
|
|||||||
|
|
||||||
// Send queues a notification for delivery
|
// Send queues a notification for delivery
|
||||||
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
|
||||||
|
// Enforce RBAC authorization if configured
|
||||||
|
if err := s.checkAuthorization(ctx, notification); err != nil {
|
||||||
|
return &domain.NotificationResult{
|
||||||
|
NotificationID: notification.ID,
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
SentAt: time.Now(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
// Store the notification
|
// Store the notification
|
||||||
s.storeNotification(notification)
|
s.storeNotification(notification)
|
||||||
|
|
||||||
@@ -301,6 +313,13 @@ func (s *NotificationService) Send(ctx context.Context, notification *domain.Not
|
|||||||
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
|
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
|
||||||
results := make([]*domain.NotificationResult, 0, len(notifications))
|
results := make([]*domain.NotificationResult, 0, len(notifications))
|
||||||
|
|
||||||
|
// Enforce RBAC authorization for each notification
|
||||||
|
for _, notification := range notifications {
|
||||||
|
if err := s.checkAuthorization(ctx, notification); err != nil {
|
||||||
|
return nil, fmt.Errorf("authorization denied for notification type=%s account=%s: %w", notification.Type, notification.Account, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store all notifications
|
// Store all notifications
|
||||||
for _, notification := range notifications {
|
for _, notification := range notifications {
|
||||||
s.storeNotification(notification)
|
s.storeNotification(notification)
|
||||||
@@ -439,12 +458,7 @@ func (s *NotificationService) GetStats(ctx context.Context) (*domain.Notificatio
|
|||||||
// GetNotifiers returns information about available notifiers, filtered by authorization if auth context is provided
|
// GetNotifiers returns information about available notifiers, filtered by authorization if auth context is provided
|
||||||
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
|
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
|
||||||
// Extract auth context from request context if available
|
// Extract auth context from request context if available
|
||||||
var authCtx *auth.AuthContext
|
authCtx, _ := auth.GetAuthContext(ctx)
|
||||||
if authVal := ctx.Value("auth"); authVal != nil {
|
|
||||||
if ac, ok := authVal.(*auth.AuthContext); ok {
|
|
||||||
authCtx = ac
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
supportedTypes := s.factory.SupportedTypes()
|
supportedTypes := s.factory.SupportedTypes()
|
||||||
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
|
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
|
||||||
@@ -510,6 +524,30 @@ func (s *NotificationService) updateNotification(notification *domain.Notificati
|
|||||||
s.notifications[notification.ID] = notification
|
s.notifications[notification.ID] = notification
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAuthorization verifies that the caller is authorized to send to the given notifier/account.
|
||||||
|
// Returns nil if authorized or if RBAC is not configured.
|
||||||
|
func (s *NotificationService) checkAuthorization(ctx context.Context, notification *domain.Notification) error {
|
||||||
|
if s.authz == nil || !s.authz.HasRules() {
|
||||||
|
return nil // RBAC not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
authCtx, ok := auth.GetAuthContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil // No auth context (auth may be disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
account := notification.Account
|
||||||
|
if account == "" && s.accountResolver != nil {
|
||||||
|
account = s.accountResolver.GetDefaultAccount(notification.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.authz.IsAuthorized(authCtx, notification.Type, account) {
|
||||||
|
return fmt.Errorf("not authorized to send %s notifications to account %s", notification.Type, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// matchesFilter checks if a notification matches the filter
|
// matchesFilter checks if a notification matches the filter
|
||||||
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
|
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
|
||||||
if filter == nil {
|
if filter == nil {
|
||||||
|
|||||||
+5
-1
@@ -50,6 +50,8 @@ spec:
|
|||||||
readOnly: true
|
readOnly: true
|
||||||
- name: queue-storage
|
- name: queue-storage
|
||||||
mountPath: /var/lib/notifier
|
mountPath: /var/lib/notifier
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
@@ -77,7 +79,7 @@ spec:
|
|||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 1000
|
runAsUser: 1000
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: false
|
readOnlyRootFilesystem: true
|
||||||
capabilities:
|
capabilities:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
@@ -87,6 +89,8 @@ spec:
|
|||||||
name: notifier-config
|
name: notifier-config
|
||||||
- name: queue-storage
|
- name: queue-storage
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
Reference in New Issue
Block a user