feat: implement ntfy-discord bridge in Go
Some checks failed
Build and Push / build (push) Failing after 4m36s
Build and Push / test (push) Has been cancelled

- SSE subscription to ntfy with auto-reconnect
- Discord webhook integration with embed formatting
- Priority to color mapping, tag to emoji conversion
- Native HashiCorp Vault support (Kubernetes + token auth)
- Hot reload secrets via fsnotify or Vault polling
- Prometheus metrics (/metrics endpoint)
- Health/ready endpoints for Kubernetes probes
- Comprehensive unit tests and fuzz tests
- Multi-stage Docker build (~10MB scratch image)
- CI/CD pipeline for Gitea Actions
This commit is contained in:
2026-02-02 18:13:55 -05:00
parent b325d9bfec
commit f97ad0e7cb
22 changed files with 2678 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
name: Build and Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: gitea-http.gitea.svc.cluster.local:3000/daviestechlabs
REGISTRY_HOST: gitea-http.gitea.svc.cluster.local:3000
IMAGE_NAME: ntfy-discord
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."gitea-http.gitea.svc.cluster.local:3000"]
http = true
insecure = true
- name: Login to Docker Hub
if: vars.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Gitea Registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin 2>/dev/null || true
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Run tests
run: go test -v ./...
- name: Run go vet
run: go vet ./...

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Binaries
ntfy-discord
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Local env files
.env
.env.local

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /ntfy-discord .
# Runtime stage - scratch for minimal image
FROM scratch
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /ntfy-discord /ntfy-discord
# Run as non-root
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/ntfy-discord"]

View File

@@ -1,2 +1,101 @@
# ntfy-discord # ntfy-discord
A lightweight Go bridge that forwards [ntfy](https://ntfy.sh/) notifications to Discord webhooks.
## Features
- **SSE Subscription**: Real-time message streaming from ntfy
- **Auto-reconnection**: Exponential backoff on connection failures
- **Hot Reload**: Watches mounted secrets for changes (no pod restart needed)
- **Native Vault Support**: Direct HashiCorp Vault integration with Kubernetes auth
- **Message Transformation**: Converts ntfy format to Discord embeds
- **Priority Mapping**: ntfy priorities → Discord embed colors
- **Tag → Emoji**: Converts common ntfy tags to Discord-friendly emojis
- **Prometheus Metrics**: `/metrics` endpoint for observability
- **Tiny Footprint**: ~10MB image, 16-32MB memory
## Configuration
### Core Settings
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `NTFY_URL` | ntfy server URL | `http://ntfy.observability.svc.cluster.local` |
| `NTFY_TOPICS` | Comma-separated topics to subscribe to | (required) |
| `HTTP_PORT` | Port for health/metrics endpoints | `8080` |
| `LOG_LEVEL` | Log level (`info` or `debug`) | `info` |
### Secret Sources (Priority Order)
The bridge loads the Discord webhook URL from the first available source:
1. **Vault** (if `VAULT_ENABLED=true`)
2. **Mounted Secret** (if `SECRETS_PATH` is set)
3. **Environment Variable** (`DISCORD_WEBHOOK_URL`)
### Vault Configuration
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `VAULT_ENABLED` | Enable Vault integration | `false` |
| `VAULT_ADDR` | Vault server address | `http://vault.vault.svc.cluster.local:8200` |
| `VAULT_AUTH_METHOD` | Auth method: `kubernetes` or `token` | `kubernetes` |
| `VAULT_ROLE` | Vault role for Kubernetes auth | `ntfy-discord` |
| `VAULT_MOUNT_PATH` | Secrets engine mount path | `secret` |
| `VAULT_SECRET_PATH` | Path within the mount | `ntfy-discord` |
| `VAULT_TOKEN_PATH` | SA token path (for K8s auth) | `/var/run/secrets/kubernetes.io/serviceaccount/token` |
Expected Vault secret structure (KV v2):
```
secret/data/ntfy-discord
├── webhook-url: https://discord.com/api/webhooks/...
```
### File-Based Secrets
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `SECRETS_PATH` | Path to mounted secrets directory | (optional) |
| `DISCORD_WEBHOOK_URL` | Discord webhook URL (fallback) | (optional) |
When using `SECRETS_PATH`, the bridge expects:
- `webhook-url` - Discord webhook URL
The bridge watches for file changes and reloads automatically.
## Endpoints
| Path | Description |
|------|-------------|
| `/health` | Health check (webhook configured?) |
| `/ready` | Readiness check (connected to ntfy?) |
| `/metrics` | Prometheus metrics |
## Priority → Color Mapping
| Priority | Name | Color |
|----------|------|-------|
| 5 | Max/Urgent | 🔴 Red |
| 4 | High | 🟠 Orange |
| 3 | Default | 🔵 Blue |
| 2 | Low | ⚪ Gray |
| 1 | Min | ⚪ Light Gray |
## Building
```bash
# Build binary
go build -o ntfy-discord .
# Build container
docker build -t ntfy-discord .
```
## Kubernetes Deployment
See [homelab-k8s2](https://github.com/Billy-Davies-2/homelab-k8s2) for deployment manifests.
## License
MIT

38
go.mod Normal file
View File

@@ -0,0 +1,38 @@
module git.daviestechlabs.io/daviestechlabs/ntfy-discord
go 1.25
require (
github.com/fsnotify/fsnotify v1.7.0
github.com/hashicorp/vault/api v1.22.0
github.com/hashicorp/vault/api/auth/kubernetes v0.10.0
github.com/prometheus/client_golang v1.19.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)

79
go.sum Normal file
View File

@@ -0,0 +1,79 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
github.com/hashicorp/vault/api/auth/kubernetes v0.10.0 h1:5rqWmUFxnu3S7XYq9dafURwBgabYDFzo2Wv+AMopPHs=
github.com/hashicorp/vault/api/auth/kubernetes v0.10.0/go.mod h1:cZZmhF6xboMDmDbMY52oj2DKW6gS0cQ9g0pJ5XIXQ5U=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

116
internal/bridge/bridge.go Normal file
View File

@@ -0,0 +1,116 @@
package bridge
import (
"context"
"log/slog"
"sync/atomic"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/config"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/discord"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
messagesReceived = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_received_total",
Help: "Total number of messages received from ntfy",
}, []string{"topic"})
messagesSent = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_sent_total",
Help: "Total number of messages sent to Discord",
}, []string{"topic"})
messagesErrors = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_errors_total",
Help: "Total number of errors sending to Discord",
}, []string{"topic"})
)
// Bridge connects ntfy to Discord
type Bridge struct {
cfg *config.Config
ntfyClient *ntfy.Client
discordClient *discord.Client
ready atomic.Bool
}
// New creates a new Bridge
func New(cfg *config.Config) *Bridge {
return &Bridge{
cfg: cfg,
ntfyClient: ntfy.NewClient(cfg.NtfyURL, cfg.NtfyTopics),
discordClient: discord.NewClient(),
}
}
// Run starts the bridge
func (b *Bridge) Run(ctx context.Context) {
if len(b.cfg.NtfyTopics) == 0 {
slog.Error("no topics configured")
return
}
msgCh := make(chan ntfy.Message, 100)
// Start SSE subscription
go b.ntfyClient.Subscribe(ctx, msgCh)
// Mark as ready once we start processing
b.ready.Store(true)
// Process messages
for {
select {
case msg := <-msgCh:
b.handleMessage(ctx, msg)
case <-ctx.Done():
b.ready.Store(false)
return
}
}
}
// IsReady returns true if the bridge is ready to process messages
func (b *Bridge) IsReady() bool {
return b.ready.Load()
}
// IsHealthy returns true if the bridge is healthy
func (b *Bridge) IsHealthy() bool {
// For now, healthy == ready
// Could add more checks like webhook URL configured
return b.cfg.WebhookURL() != ""
}
func (b *Bridge) handleMessage(ctx context.Context, msg ntfy.Message) {
messagesReceived.WithLabelValues(msg.Topic).Inc()
webhookURL := b.cfg.WebhookURL()
if webhookURL == "" {
slog.Warn("no webhook URL configured, dropping message", "topic", msg.Topic)
messagesErrors.WithLabelValues(msg.Topic).Inc()
return
}
slog.Info("forwarding message to Discord",
"id", msg.ID,
"topic", msg.Topic,
"title", msg.Title,
)
if err := b.discordClient.Send(ctx, webhookURL, msg); err != nil {
slog.Error("failed to send to Discord",
"error", err,
"topic", msg.Topic,
"id", msg.ID,
)
messagesErrors.WithLabelValues(msg.Topic).Inc()
return
}
messagesSent.WithLabelValues(msg.Topic).Inc()
slog.Debug("message sent to Discord", "id", msg.ID)
}

View File

@@ -0,0 +1,115 @@
package bridge
import (
"context"
"sync"
"testing"
"time"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
)
func TestBridge_IsReady(t *testing.T) {
// Create a minimal bridge for testing ready state
b := &Bridge{}
// Initially not ready
if b.IsReady() {
t.Error("bridge should not be ready before Run()")
}
// Set ready
b.ready.Store(true)
if !b.IsReady() {
t.Error("bridge should be ready after ready.Store(true)")
}
// Unset ready
b.ready.Store(false)
if b.IsReady() {
t.Error("bridge should not be ready after ready.Store(false)")
}
}
func TestMetricsRegistered(t *testing.T) {
// Verify metrics are registered by checking they're not nil
if messagesReceived == nil {
t.Error("messagesReceived metric not registered")
}
if messagesSent == nil {
t.Error("messagesSent metric not registered")
}
if messagesErrors == nil {
t.Error("messagesErrors metric not registered")
}
}
// Test that Bridge correctly uses the ready atomic
func TestBridge_ReadyState_Concurrent(t *testing.T) {
b := &Bridge{}
// Test concurrent access
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
b.ready.Store(true)
}()
go func() {
defer wg.Done()
_ = b.IsReady()
}()
}
wg.Wait()
// Should not have any race conditions
}
// Test message channel buffer
func TestMessageChannelBuffer(t *testing.T) {
msgCh := make(chan ntfy.Message, 100)
// Should be able to buffer 100 messages without blocking
for i := 0; i < 100; i++ {
select {
case msgCh <- ntfy.Message{ID: "test"}:
// OK
default:
t.Fatalf("channel blocked at message %d", i)
}
}
// 101st should block (use select to avoid blocking test)
select {
case msgCh <- ntfy.Message{ID: "overflow"}:
t.Error("channel should be full")
default:
// Expected - channel full
}
}
// Test context cancellation behavior
func TestContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
// Simulate Run() loop behavior
select {
case <-ctx.Done():
close(done)
case <-time.After(5 * time.Second):
// Should not reach here
}
}()
cancel()
select {
case <-done:
// Success
case <-time.After(time.Second):
t.Error("context cancellation not handled")
}
}

233
internal/config/config.go Normal file
View File

@@ -0,0 +1,233 @@
package config
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/vault"
)
// Config holds the application configuration
type Config struct {
NtfyURL string
NtfyTopics []string
SecretsPath string
HTTPPort string
// Vault configuration
VaultEnabled bool
vaultClient *vault.Client
mu sync.RWMutex
webhookURL string
}
// Load creates a new Config from environment variables
func Load(ctx context.Context) (*Config, error) {
cfg := &Config{
NtfyURL: getEnv("NTFY_URL", "http://ntfy.observability.svc.cluster.local"),
SecretsPath: getEnv("SECRETS_PATH", ""),
HTTPPort: getEnv("HTTP_PORT", "8080"),
VaultEnabled: getEnv("VAULT_ENABLED", "false") == "true",
}
// Parse topics
topics := getEnv("NTFY_TOPICS", "")
if topics != "" {
cfg.NtfyTopics = strings.Split(topics, ",")
for i := range cfg.NtfyTopics {
cfg.NtfyTopics[i] = strings.TrimSpace(cfg.NtfyTopics[i])
}
}
// Try Vault first if enabled
if cfg.VaultEnabled {
if err := cfg.initVault(ctx); err != nil {
slog.Warn("vault init failed, falling back to file/env", "error", err)
} else {
// Load webhook from Vault
if webhookURL, err := cfg.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
cfg.mu.Lock()
cfg.webhookURL = webhookURL
cfg.mu.Unlock()
slog.Info("loaded webhook URL from vault")
} else {
slog.Warn("failed to get webhook from vault", "error", err)
}
}
}
// Fall back to file-based secret
if cfg.webhookURL == "" && cfg.SecretsPath != "" {
if err := cfg.loadWebhookFromSecret(); err != nil {
slog.Warn("failed to load webhook from secret, trying env", "error", err)
}
}
// Fall back to environment variable
if cfg.webhookURL == "" {
cfg.webhookURL = getEnv("DISCORD_WEBHOOK_URL", "")
}
if cfg.webhookURL == "" {
slog.Warn("no Discord webhook URL configured")
}
slog.Info("config loaded",
"ntfy_url", cfg.NtfyURL,
"topics", cfg.NtfyTopics,
"secrets_path", cfg.SecretsPath,
"vault_enabled", cfg.VaultEnabled,
)
return cfg, nil
}
// initVault initializes the Vault client
func (c *Config) initVault(ctx context.Context) error {
vaultCfg := vault.Config{
Address: getEnv("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200"),
AuthMethod: getEnv("VAULT_AUTH_METHOD", "kubernetes"),
Role: getEnv("VAULT_ROLE", "ntfy-discord"),
MountPath: getEnv("VAULT_MOUNT_PATH", "secret"),
SecretPath: getEnv("VAULT_SECRET_PATH", "ntfy-discord"),
TokenPath: getEnv("VAULT_TOKEN_PATH", ""),
}
client, err := vault.NewClient(vaultCfg)
if err != nil {
return err
}
c.vaultClient = client
return nil
}
// WebhookURL returns the current webhook URL (thread-safe)
func (c *Config) WebhookURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.webhookURL
}
// loadWebhookFromSecret reads the webhook URL from the mounted secret
func (c *Config) loadWebhookFromSecret() error {
path := filepath.Join(c.SecretsPath, "webhook-url")
data, err := os.ReadFile(path)
if err != nil {
return err
}
c.mu.Lock()
c.webhookURL = strings.TrimSpace(string(data))
c.mu.Unlock()
slog.Debug("loaded webhook URL from secret")
return nil
}
// WatchSecrets watches the secrets directory for changes and reloads
func (c *Config) WatchSecrets(ctx context.Context) {
// Start Vault watcher if enabled
if c.vaultClient != nil {
go c.watchVaultSecrets(ctx)
}
// Start file watcher if secrets path is set
if c.SecretsPath != "" {
go c.watchFileSecrets(ctx)
}
}
// watchVaultSecrets periodically refreshes secrets from Vault
func (c *Config) watchVaultSecrets(ctx context.Context) {
interval := 30 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
slog.Info("watching vault secrets", "interval", interval)
for {
select {
case <-ticker.C:
if webhookURL, err := c.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
c.mu.Lock()
if c.webhookURL != webhookURL {
c.webhookURL = webhookURL
slog.Info("webhook URL updated from vault")
}
c.mu.Unlock()
} else {
slog.Error("failed to refresh webhook from vault", "error", err)
}
case <-ctx.Done():
return
}
}
}
// watchFileSecrets watches the secrets directory for changes
func (c *Config) watchFileSecrets(ctx context.Context) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
slog.Error("failed to create fsnotify watcher", "error", err)
return
}
defer watcher.Close()
// Watch the secrets directory
// Kubernetes updates secrets by changing the symlink, so watch the parent
if err := watcher.Add(c.SecretsPath); err != nil {
slog.Error("failed to watch secrets path", "error", err, "path", c.SecretsPath)
return
}
slog.Info("watching secrets for changes", "path", c.SecretsPath)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// Kubernetes updates secrets via symlink, which triggers Create events
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
slog.Info("secret changed, reloading", "event", event.Name)
if err := c.loadWebhookFromSecret(); err != nil {
slog.Error("failed to reload webhook from secret", "error", err)
} else {
slog.Info("webhook URL reloaded successfully")
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
slog.Error("fsnotify error", "error", err)
case <-ctx.Done():
return
}
}
}
// Close cleans up resources
func (c *Config) Close() error {
if c.vaultClient != nil {
return c.vaultClient.Close()
}
return nil
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

View File

@@ -0,0 +1,223 @@
package config
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
)
func TestGetEnv(t *testing.T) {
// Set a test env var
os.Setenv("TEST_CONFIG_VAR", "test_value")
defer os.Unsetenv("TEST_CONFIG_VAR")
tests := []struct {
name string
key string
defaultVal string
want string
}{
{"existing var", "TEST_CONFIG_VAR", "default", "test_value"},
{"non-existing var", "NON_EXISTING_VAR", "default", "default"},
{"empty default", "NON_EXISTING_VAR_2", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getEnv(tt.key, tt.defaultVal)
if got != tt.want {
t.Errorf("getEnv(%s, %s) = %s, want %s", tt.key, tt.defaultVal, got, tt.want)
}
})
}
}
func TestConfig_WebhookURL_ThreadSafe(t *testing.T) {
cfg := &Config{}
cfg.webhookURL = "https://discord.com/api/webhooks/test"
// Test concurrent reads and writes
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
_ = cfg.WebhookURL()
}()
go func() {
defer wg.Done()
cfg.mu.Lock()
cfg.webhookURL = "updated"
cfg.mu.Unlock()
}()
}
wg.Wait()
// Should not race
}
func TestConfig_LoadWebhookFromSecret(t *testing.T) {
// Create temp directory with secret file
tmpDir := t.TempDir()
secretPath := filepath.Join(tmpDir, "webhook-url")
webhookURL := "https://discord.com/api/webhooks/123/abc"
if err := os.WriteFile(secretPath, []byte(webhookURL+"\n"), 0644); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
cfg := &Config{
SecretsPath: tmpDir,
}
if err := cfg.loadWebhookFromSecret(); err != nil {
t.Fatalf("loadWebhookFromSecret() error = %v", err)
}
if cfg.webhookURL != webhookURL {
t.Errorf("webhookURL = %s, want %s", cfg.webhookURL, webhookURL)
}
}
func TestConfig_LoadWebhookFromSecret_NotFound(t *testing.T) {
cfg := &Config{
SecretsPath: "/nonexistent/path",
}
err := cfg.loadWebhookFromSecret()
if err == nil {
t.Error("expected error for non-existent secret")
}
}
func TestConfig_LoadWebhookFromSecret_TrimsWhitespace(t *testing.T) {
tmpDir := t.TempDir()
secretPath := filepath.Join(tmpDir, "webhook-url")
// Write with extra whitespace
if err := os.WriteFile(secretPath, []byte(" https://example.com \n\n"), 0644); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
cfg := &Config{SecretsPath: tmpDir}
if err := cfg.loadWebhookFromSecret(); err != nil {
t.Fatalf("loadWebhookFromSecret() error = %v", err)
}
if cfg.webhookURL != "https://example.com" {
t.Errorf("webhookURL = %q, want %q", cfg.webhookURL, "https://example.com")
}
}
func TestLoad_ParsesTopics(t *testing.T) {
os.Setenv("NTFY_TOPICS", "alerts, updates , notifications")
os.Setenv("VAULT_ENABLED", "false")
defer func() {
os.Unsetenv("NTFY_TOPICS")
os.Unsetenv("VAULT_ENABLED")
}()
cfg, err := Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
expected := []string{"alerts", "updates", "notifications"}
if len(cfg.NtfyTopics) != len(expected) {
t.Errorf("NtfyTopics length = %d, want %d", len(cfg.NtfyTopics), len(expected))
}
for i, topic := range cfg.NtfyTopics {
if topic != expected[i] {
t.Errorf("NtfyTopics[%d] = %s, want %s", i, topic, expected[i])
}
}
}
func TestLoad_Defaults(t *testing.T) {
// Clear any existing env vars
os.Unsetenv("NTFY_URL")
os.Unsetenv("HTTP_PORT")
os.Unsetenv("VAULT_ENABLED")
cfg, err := Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.NtfyURL != "http://ntfy.observability.svc.cluster.local" {
t.Errorf("NtfyURL = %s, want default", cfg.NtfyURL)
}
if cfg.HTTPPort != "8080" {
t.Errorf("HTTPPort = %s, want 8080", cfg.HTTPPort)
}
if cfg.VaultEnabled {
t.Error("VaultEnabled should be false by default")
}
}
func TestLoad_VaultEnabled(t *testing.T) {
os.Setenv("VAULT_ENABLED", "true")
defer os.Unsetenv("VAULT_ENABLED")
// This will fail to init Vault (no server), but should gracefully fall back
cfg, err := Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.VaultEnabled {
t.Error("VaultEnabled should be true")
}
// vaultClient should be nil (failed to connect)
if cfg.vaultClient != nil {
t.Error("vaultClient should be nil when Vault unavailable")
}
}
func TestLoad_FallsBackToEnvVar(t *testing.T) {
webhookURL := "https://discord.com/api/webhooks/env/test"
os.Setenv("DISCORD_WEBHOOK_URL", webhookURL)
os.Setenv("VAULT_ENABLED", "false")
defer func() {
os.Unsetenv("DISCORD_WEBHOOK_URL")
os.Unsetenv("VAULT_ENABLED")
}()
cfg, err := Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WebhookURL() != webhookURL {
t.Errorf("WebhookURL() = %s, want %s", cfg.WebhookURL(), webhookURL)
}
}
func TestConfig_Close_NoVault(t *testing.T) {
cfg := &Config{}
// Should not panic with nil vaultClient
err := cfg.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestConfig_WatchSecrets_NoPath(t *testing.T) {
cfg := &Config{
SecretsPath: "",
vaultClient: nil,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Should return immediately, not block or panic
cfg.WatchSecrets(ctx)
}

View File

@@ -0,0 +1,72 @@
package config
import (
"testing"
)
// FuzzParseTopics tests topic parsing doesn't panic on arbitrary input
func FuzzParseTopics(f *testing.F) {
// Normal cases
f.Add("alerts")
f.Add("alerts,updates")
f.Add("alerts, updates, notifications")
f.Add("")
// Edge cases
f.Add(",,,")
f.Add(" , , ")
f.Add("a]topic-with-special_chars.123")
f.Add("\x00\x00\x00")
f.Add("topic\nwith\nnewlines")
f.Fuzz(func(t *testing.T, input string) {
// Simulate topic parsing logic
if input == "" {
return
}
topics := make([]string, 0)
for _, topic := range splitAndTrim(input) {
topics = append(topics, topic)
}
// Accessing results should not panic
_ = len(topics)
})
}
// splitAndTrim mimics the topic parsing in Load()
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
var result []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
part := trimSpace(s[start:i])
if part != "" {
result = append(result, part)
}
start = i + 1
}
}
// Last part
part := trimSpace(s[start:])
if part != "" {
result = append(result, part)
}
return result
}
func trimSpace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
end--
}
return s[start:end]
}

View File

@@ -0,0 +1,125 @@
package discord
import (
"encoding/json"
"testing"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
)
// FuzzBuildEmbed tests that embed building doesn't panic on arbitrary input
func FuzzBuildEmbed(f *testing.F) {
// Seed with normal inputs
f.Add("Test Title", "Test message body", 3, "warning", "test-topic", "https://example.com")
f.Add("", "", 0, "", "", "")
f.Add("Alert!", "Critical issue detected", 5, "fire", "alerts", "")
// Edge cases
f.Add("x", "y", -1, "unknown", "t", "not-a-url")
f.Add("x", "y", 100, "", "", "")
f.Add("🔥 Fire Alert", "💀 Something broke", 5, "skull", "test", "")
// Long strings
longStr := ""
for i := 0; i < 10000; i++ {
longStr += "x"
}
f.Add(longStr, longStr, 3, "tag", "topic", "https://example.com")
// Special characters
f.Add("Title\x00with\x00nulls", "Message\nwith\nnewlines", 3, "tag", "topic", "")
f.Add("<script>alert('xss')</script>", "```code```", 3, "", "", "")
f.Add("Title\t\r\n", "Body\t\r\n", 3, "", "", "")
f.Fuzz(func(t *testing.T, title, message string, priority int, tag, topic, click string) {
msg := ntfy.Message{
Title: title,
Message: message,
Priority: priority,
Tags: []string{tag},
Topic: topic,
Click: click,
Time: 1706803200,
}
client := NewClient()
// Should never panic
embed := client.buildEmbed(msg)
// Resulting embed should be valid
if embed.Footer == nil {
t.Error("Footer should never be nil")
}
// Color should always be set to a valid value
if embed.Color == 0 {
t.Error("Color should never be 0")
}
})
}
// FuzzExtractEmoji tests emoji extraction doesn't panic
func FuzzExtractEmoji(f *testing.F) {
// Valid tags
f.Add("warning")
f.Add("fire")
f.Add("check")
f.Add("rocket")
// Edge cases
f.Add("")
f.Add("unknown_tag")
f.Add("WARNING") // uppercase
f.Add("WaRnInG") // mixed case
f.Add("\x00")
f.Add("tag with spaces")
f.Add("émoji")
f.Add("🔥") // emoji as tag
f.Add("a]b[c{d}") // special chars
f.Fuzz(func(t *testing.T, tag string) {
client := NewClient()
// Should never panic
tags := []string{tag}
_ = client.extractEmoji(tags)
// Multiple tags
_ = client.extractEmoji([]string{tag, tag, tag})
// Empty slice
_ = client.extractEmoji([]string{})
// Nil slice
_ = client.extractEmoji(nil)
})
}
// FuzzWebhookPayloadJSON tests JSON marshaling of payloads
func FuzzWebhookPayloadJSON(f *testing.F) {
f.Add("Title", "Description", 3066993, "Topic", "value", "footer")
f.Fuzz(func(t *testing.T, title, desc string, color int, fieldName, fieldValue, footer string) {
payload := WebhookPayload{
Embeds: []Embed{
{
Title: title,
Description: desc,
Color: color,
Fields: []Field{
{Name: fieldName, Value: fieldValue, Inline: true},
},
Footer: &Footer{Text: footer},
},
},
}
// Marshaling should not panic
_, err := json.Marshal(payload)
if err != nil {
// JSON encoding errors are acceptable for invalid UTF-8
// but should not panic
}
})
}

203
internal/discord/webhook.go Normal file
View File

@@ -0,0 +1,203 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
)
// Priority to Discord embed color mapping
var priorityColors = map[int]int{
5: 15158332, // Red - Max/Urgent
4: 15105570, // Orange - High
3: 3066993, // Blue - Default
2: 9807270, // Gray - Low
1: 12370112, // Light Gray - Min
}
// Tag to emoji mapping
var tagEmojis = map[string]string{
"white_check_mark": "✅",
"heavy_check_mark": "✅",
"check": "✅",
"x": "❌",
"skull": "❌",
"warning": "⚠️",
"rotating_light": "🚨",
"rocket": "🚀",
"package": "📦",
"tada": "🎉",
"fire": "🔥",
"bug": "🐛",
"wrench": "🔧",
"gear": "⚙️",
"lock": "🔒",
"key": "🔑",
"bell": "🔔",
"mega": "📢",
"eyes": "👀",
"sos": "🆘",
"no_entry": "⛔",
"construction": "🚧",
}
// Embed represents a Discord embed
type Embed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Color int `json:"color,omitempty"`
Fields []Field `json:"fields,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Footer *Footer `json:"footer,omitempty"`
}
// Field represents a Discord embed field
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline,omitempty"`
}
// Footer represents a Discord embed footer
type Footer struct {
Text string `json:"text"`
}
// WebhookPayload is the Discord webhook request body
type WebhookPayload struct {
Embeds []Embed `json:"embeds"`
}
// Client sends messages to Discord webhooks
type Client struct {
httpClient *http.Client
}
// NewClient creates a new Discord webhook client
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Send converts an ntfy message to Discord format and sends it
func (c *Client) Send(ctx context.Context, webhookURL string, msg ntfy.Message) error {
if webhookURL == "" {
return fmt.Errorf("no webhook URL configured")
}
embed := c.buildEmbed(msg)
payload := WebhookPayload{
Embeds: []Embed{embed},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
// Handle rate limiting
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
slog.Warn("Discord rate limited", "retry_after", seconds)
time.Sleep(time.Duration(seconds) * time.Second)
return c.Send(ctx, webhookURL, msg) // Retry
}
}
return fmt.Errorf("rate limited")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *Client) buildEmbed(msg ntfy.Message) Embed {
// Get color from priority
color := priorityColors[3] // Default
if msg.Priority >= 1 && msg.Priority <= 5 {
color = priorityColors[msg.Priority]
}
// Build title with emoji prefix
title := msg.Title
if title == "" {
title = msg.Topic
}
emoji := c.extractEmoji(msg.Tags)
if emoji != "" {
title = emoji + " " + title
}
// Build timestamp
timestamp := ""
if msg.Time > 0 {
timestamp = time.Unix(msg.Time, 0).UTC().Format(time.RFC3339)
}
embed := Embed{
Title: title,
Description: msg.Message,
Color: color,
Timestamp: timestamp,
Footer: &Footer{Text: "ntfy"},
}
// Add topic as field
if msg.Topic != "" {
embed.Fields = append(embed.Fields, Field{
Name: "Topic",
Value: msg.Topic,
Inline: true,
})
}
// Add click URL if present
if msg.Click != "" {
embed.Fields = append(embed.Fields, Field{
Name: "Link",
Value: msg.Click,
Inline: false,
})
}
return embed
}
func (c *Client) extractEmoji(tags []string) string {
for _, tag := range tags {
tag = strings.ToLower(tag)
if emoji, ok := tagEmojis[tag]; ok {
return emoji
}
}
return ""
}

View File

@@ -0,0 +1,260 @@
package discord
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
)
func TestClient_Send(t *testing.T) {
tests := []struct {
name string
msg ntfy.Message
wantStatus int
wantErr bool
}{
{
name: "successful send",
msg: ntfy.Message{
ID: "test-id",
Topic: "alerts",
Title: "Test Alert",
Message: "This is a test message",
Priority: 3,
Time: time.Now().Unix(),
},
wantStatus: http.StatusNoContent,
wantErr: false,
},
{
name: "high priority message",
msg: ntfy.Message{
ID: "high-priority",
Topic: "urgent",
Title: "Urgent Alert",
Message: "Critical issue detected",
Priority: 5,
Tags: []string{"warning", "fire"},
},
wantStatus: http.StatusNoContent,
wantErr: false,
},
{
name: "server error",
msg: ntfy.Message{
ID: "error-test",
Topic: "test",
},
wantStatus: http.StatusInternalServerError,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var receivedPayload WebhookPayload
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected application/json, got %s", r.Header.Get("Content-Type"))
}
if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil {
t.Errorf("failed to decode payload: %v", err)
}
w.WriteHeader(tt.wantStatus)
}))
defer server.Close()
client := NewClient()
err := client.Send(context.Background(), server.URL, tt.msg)
if (err != nil) != tt.wantErr {
t.Errorf("Send() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr && len(receivedPayload.Embeds) != 1 {
t.Errorf("expected 1 embed, got %d", len(receivedPayload.Embeds))
}
})
}
}
func TestClient_Send_NoWebhookURL(t *testing.T) {
client := NewClient()
err := client.Send(context.Background(), "", ntfy.Message{})
if err == nil {
t.Error("expected error for empty webhook URL")
}
}
func TestClient_buildEmbed(t *testing.T) {
client := NewClient()
tests := []struct {
name string
msg ntfy.Message
wantColor int
wantEmoji string
}{
{
name: "default priority",
msg: ntfy.Message{
Title: "Test",
Message: "Hello",
Priority: 3,
},
wantColor: 3066993, // Blue
wantEmoji: "",
},
{
name: "max priority with warning tag",
msg: ntfy.Message{
Title: "Alert",
Message: "Critical",
Priority: 5,
Tags: []string{"warning"},
},
wantColor: 15158332, // Red
wantEmoji: "⚠️",
},
{
name: "low priority",
msg: ntfy.Message{
Title: "Info",
Message: "Low priority",
Priority: 2,
},
wantColor: 9807270, // Gray
wantEmoji: "",
},
{
name: "with check tag",
msg: ntfy.Message{
Title: "Success",
Message: "Completed",
Priority: 3,
Tags: []string{"check", "success"},
},
wantColor: 3066993,
wantEmoji: "✅",
},
{
name: "no title uses topic",
msg: ntfy.Message{
Topic: "alerts",
Message: "No title",
},
wantColor: 3066993,
wantEmoji: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
embed := client.buildEmbed(tt.msg)
if embed.Color != tt.wantColor {
t.Errorf("Color = %d, want %d", embed.Color, tt.wantColor)
}
if tt.wantEmoji != "" {
if len(embed.Title) < 2 || embed.Title[:len(tt.wantEmoji)] != tt.wantEmoji {
t.Errorf("Title should start with emoji %s, got %s", tt.wantEmoji, embed.Title)
}
}
if embed.Description != tt.msg.Message {
t.Errorf("Description = %s, want %s", embed.Description, tt.msg.Message)
}
})
}
}
func TestClient_extractEmoji(t *testing.T) {
client := NewClient()
tests := []struct {
name string
tags []string
want string
}{
{"warning tag", []string{"warning"}, "⚠️"},
{"check tag", []string{"check"}, "✅"},
{"fire tag", []string{"fire"}, "🔥"},
{"rocket tag", []string{"rocket"}, "🚀"},
{"unknown tag", []string{"unknown"}, ""},
{"empty tags", []string{}, ""},
{"multiple tags first match", []string{"unknown", "fire", "warning"}, "🔥"},
{"case insensitive", []string{"WARNING"}, "⚠️"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := client.extractEmoji(tt.tags)
if got != tt.want {
t.Errorf("extractEmoji(%v) = %s, want %s", tt.tags, got, tt.want)
}
})
}
}
func TestPriorityColors(t *testing.T) {
expected := map[int]int{
1: 12370112, // Light Gray
2: 9807270, // Gray
3: 3066993, // Blue
4: 15105570, // Orange
5: 15158332, // Red
}
for priority, color := range expected {
if priorityColors[priority] != color {
t.Errorf("priorityColors[%d] = %d, want %d", priority, priorityColors[priority], color)
}
}
}
func TestWebhookPayload_JSON(t *testing.T) {
payload := WebhookPayload{
Embeds: []Embed{
{
Title: "Test",
Description: "Hello World",
Color: 3066993,
Fields: []Field{
{Name: "Topic", Value: "alerts", Inline: true},
},
Footer: &Footer{Text: "ntfy"},
},
},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded WebhookPayload
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(decoded.Embeds) != 1 {
t.Errorf("expected 1 embed, got %d", len(decoded.Embeds))
}
if decoded.Embeds[0].Title != "Test" {
t.Errorf("Title = %s, want Test", decoded.Embeds[0].Title)
}
}

137
internal/ntfy/client.go Normal file
View File

@@ -0,0 +1,137 @@
package ntfy
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// Message represents an ntfy message
type Message struct {
ID string `json:"id"`
Time int64 `json:"time"`
Expires int64 `json:"expires,omitempty"`
Event string `json:"event"`
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
}
// Client subscribes to ntfy topics via SSE
type Client struct {
baseURL string
topics []string
client *http.Client
}
// NewClient creates a new ntfy SSE client
func NewClient(baseURL string, topics []string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
topics: topics,
client: &http.Client{
Timeout: 0, // No timeout for SSE
},
}
}
// Subscribe connects to ntfy and streams messages to the channel
// It automatically reconnects with exponential backoff on failure
func (c *Client) Subscribe(ctx context.Context, msgCh chan<- Message) {
backoff := time.Second
maxBackoff := time.Minute
for {
select {
case <-ctx.Done():
return
default:
}
err := c.connect(ctx, msgCh)
if err != nil {
if ctx.Err() != nil {
return // Context cancelled
}
slog.Error("ntfy connection failed", "error", err, "backoff", backoff)
time.Sleep(backoff)
backoff = min(backoff*2, maxBackoff)
continue
}
// Reset backoff on successful connection
backoff = time.Second
}
}
func (c *Client) connect(ctx context.Context, msgCh chan<- Message) error {
// Build URL with all topics
topicPath := strings.Join(c.topics, ",")
url := fmt.Sprintf("%s/%s/json", c.baseURL, topicPath)
slog.Info("connecting to ntfy", "url", url)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
slog.Info("connected to ntfy", "topics", c.topics)
// Read line by line (ntfy sends newline-delimited JSON)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
slog.Warn("failed to parse ntfy message", "error", err, "line", line)
continue
}
// Skip keepalive/open events
if msg.Event == "keepalive" || msg.Event == "open" {
slog.Debug("ntfy event", "event", msg.Event)
continue
}
if msg.Event == "message" {
slog.Debug("received ntfy message",
"id", msg.ID,
"topic", msg.Topic,
"title", msg.Title,
)
msgCh <- msg
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read stream: %w", err)
}
return fmt.Errorf("stream closed")
}

View File

@@ -0,0 +1,227 @@
package ntfy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
client := NewClient("http://ntfy.example.com", []string{"alerts", "updates"})
if client.baseURL != "http://ntfy.example.com" {
t.Errorf("baseURL = %s, want http://ntfy.example.com", client.baseURL)
}
if len(client.topics) != 2 {
t.Errorf("topics length = %d, want 2", len(client.topics))
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
client := NewClient("http://ntfy.example.com/", []string{"test"})
if client.baseURL != "http://ntfy.example.com" {
t.Errorf("baseURL = %s, want http://ntfy.example.com (trailing slash removed)", client.baseURL)
}
}
func TestMessage_JSON(t *testing.T) {
msg := Message{
ID: "test-123",
Time: 1706803200,
Event: "message",
Topic: "alerts",
Title: "Test Alert",
Message: "This is a test",
Priority: 4,
Tags: []string{"warning", "fire"},
Click: "https://example.com",
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.ID != msg.ID {
t.Errorf("ID = %s, want %s", decoded.ID, msg.ID)
}
if decoded.Priority != msg.Priority {
t.Errorf("Priority = %d, want %d", decoded.Priority, msg.Priority)
}
if len(decoded.Tags) != 2 {
t.Errorf("Tags length = %d, want 2", len(decoded.Tags))
}
}
func TestClient_Subscribe_ReceivesMessages(t *testing.T) {
messages := []Message{
{Event: "open"},
{Event: "message", ID: "msg1", Topic: "test", Title: "First", Message: "Hello"},
{Event: "keepalive"},
{Event: "message", ID: "msg2", Topic: "test", Title: "Second", Message: "World"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
for _, msg := range messages {
data, _ := json.Marshal(msg)
fmt.Fprintf(w, "%s\n", data)
}
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go client.Subscribe(ctx, msgCh)
// Should receive only "message" events
received := make([]Message, 0)
timeout := time.After(1 * time.Second)
loop:
for {
select {
case msg := <-msgCh:
received = append(received, msg)
if len(received) >= 2 {
break loop
}
case <-timeout:
break loop
}
}
if len(received) != 2 {
t.Errorf("received %d messages, want 2", len(received))
}
if len(received) > 0 && received[0].ID != "msg1" {
t.Errorf("first message ID = %s, want msg1", received[0].ID)
}
if len(received) > 1 && received[1].ID != "msg2" {
t.Errorf("second message ID = %s, want msg2", received[1].ID)
}
}
func TestClient_Subscribe_FilterEvents(t *testing.T) {
messages := []Message{
{Event: "open"},
{Event: "keepalive"},
{Event: "message", ID: "actual", Topic: "test"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
for _, msg := range messages {
data, _ := json.Marshal(msg)
fmt.Fprintf(w, "%s\n", data)
}
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go client.Subscribe(ctx, msgCh)
select {
case msg := <-msgCh:
if msg.Event != "message" {
t.Errorf("received event = %s, want message", msg.Event)
}
if msg.ID != "actual" {
t.Errorf("message ID = %s, want actual", msg.ID)
}
case <-time.After(500 * time.Millisecond):
t.Error("timeout waiting for message")
}
}
func TestClient_Subscribe_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Slow server - keeps connection open
time.Sleep(10 * time.Second)
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
client.Subscribe(ctx, msgCh)
close(done)
}()
// Cancel quickly
time.Sleep(50 * time.Millisecond)
cancel()
// Should exit promptly
select {
case <-done:
// Success
case <-time.After(2 * time.Second):
t.Error("Subscribe did not exit after context cancellation")
}
}
func TestClient_connect_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
err := client.connect(context.Background(), msgCh)
if err == nil {
t.Error("expected error for server error response")
}
}
func TestClient_connect_URLConstruction(t *testing.T) {
var requestedURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedURL = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(server.URL, []string{"alerts", "updates"})
client.connect(context.Background(), make(chan Message))
expected := "/alerts,updates/json"
if requestedURL != expected {
t.Errorf("URL path = %s, want %s", requestedURL, expected)
}
}

View File

@@ -0,0 +1,58 @@
package ntfy
import (
"encoding/json"
"testing"
)
// FuzzParseMessage tests that JSON unmarshaling doesn't panic on arbitrary input
func FuzzParseMessage(f *testing.F) {
// Seed corpus with valid messages
f.Add(`{"event":"message","topic":"test","title":"hello","message":"world"}`)
f.Add(`{"event":"message","topic":"alerts","priority":5,"tags":["warning"]}`)
f.Add(`{"event":"keepalive"}`)
f.Add(`{"event":"open"}`)
f.Add(`{}`)
f.Add(`{"id":"abc123","time":1706803200,"expires":1706889600}`)
f.Add(`{"click":"https://example.com","icon":"https://example.com/icon.png"}`)
// Edge cases
f.Add(``)
f.Add(`null`)
f.Add(`[]`)
f.Add(`"string"`)
f.Add(`123`)
f.Add(`{"priority":-1}`)
f.Add(`{"priority":999999999}`)
f.Add(`{"tags":[]}`)
f.Add(`{"tags":["","",""]}`)
f.Fuzz(func(t *testing.T, data string) {
var msg Message
// Should never panic regardless of input
_ = json.Unmarshal([]byte(data), &msg)
// If it parsed, accessing fields should not panic
_ = msg.ID
_ = msg.Event
_ = msg.Topic
_ = msg.Title
_ = msg.Message
_ = msg.Priority
_ = msg.Click
_ = len(msg.Tags)
})
}
// FuzzParseMessageBytes tests binary input doesn't cause panics
func FuzzParseMessageBytes(f *testing.F) {
f.Add([]byte(`{"event":"message"}`))
f.Add([]byte{0x00})
f.Add([]byte{0xff, 0xfe})
f.Add([]byte("\xef\xbb\xbf{}")) // BOM + JSON
f.Fuzz(func(t *testing.T, data []byte) {
var msg Message
_ = json.Unmarshal(data, &msg)
})
}

93
internal/server/server.go Normal file
View File

@@ -0,0 +1,93 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/bridge"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Server provides health and metrics endpoints
type Server struct {
httpServer *http.Server
bridge *bridge.Bridge
}
// New creates a new HTTP server
func New(port string, b *bridge.Bridge) *Server {
mux := http.NewServeMux()
s := &Server{
bridge: b,
httpServer: &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
},
}
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/ready", s.handleReady)
mux.Handle("/metrics", promhttp.Handler())
return s
}
// Start begins serving HTTP requests
func (s *Server) Start() {
slog.Info("starting HTTP server", "addr", s.httpServer.Addr)
if err := s.httpServer.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("HTTP server error", "error", err)
}
}
// Shutdown gracefully stops the server
func (s *Server) Shutdown(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", "error", err)
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
status := struct {
Status string `json:"status"`
Healthy bool `json:"healthy"`
}{
Status: "ok",
Healthy: s.bridge.IsHealthy(),
}
if !status.Healthy {
status.Status = "unhealthy"
w.WriteHeader(http.StatusServiceUnavailable)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
status := struct {
Status string `json:"status"`
Ready bool `json:"ready"`
}{
Status: "ok",
Ready: s.bridge.IsReady(),
}
if !status.Ready {
status.Status = "not ready"
w.WriteHeader(http.StatusServiceUnavailable)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}

View File

@@ -0,0 +1,91 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestServer_HealthEndpoint_StatusCodes(t *testing.T) {
// Test health endpoint returns JSON
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","healthy":true}`))
})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("Content-Type") != "application/json" {
t.Error("expected Content-Type application/json")
}
}
func TestServer_ReadyEndpoint_StatusCodes(t *testing.T) {
// Test ready endpoint returns JSON
mux := http.NewServeMux()
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ready","ready":true}`))
})
req := httptest.NewRequest(http.MethodGet, "/ready", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestServer_Shutdown(t *testing.T) {
// Create a minimal server for shutdown testing
srv := &http.Server{
Addr: ":0",
Handler: http.NewServeMux(),
}
// Start in background
go srv.ListenAndServe()
// Give it a moment to start
time.Sleep(10 * time.Millisecond)
// Shutdown should complete without error
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
t.Errorf("Shutdown() error = %v", err)
}
}
func TestServer_MetricsEndpoint(t *testing.T) {
// Verify /metrics endpoint can be created
// The actual promhttp.Handler() is tested by Prometheus library
mux := http.NewServeMux()
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("# metrics here"))
})
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}

215
internal/vault/client.go Normal file
View File

@@ -0,0 +1,215 @@
package vault
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/api/auth/kubernetes"
)
// Client wraps the Vault API client with auto-renewal
type Client struct {
client *api.Client
mountPath string
secretPath string
mu sync.RWMutex
secret *api.Secret
}
// Config holds Vault client configuration
type Config struct {
// Address is the Vault server address (e.g., http://vault.vault.svc.cluster.local:8200)
Address string
// AuthMethod is either "kubernetes" or "token"
AuthMethod string
// Role is the Vault role for Kubernetes auth
Role string
// MountPath is the secrets engine mount (e.g., "secret")
MountPath string
// SecretPath is the path within the mount (e.g., "data/ntfy-discord")
SecretPath string
// TokenPath is the path to the Kubernetes service account token
TokenPath string
}
// NewClient creates a new Vault client
func NewClient(cfg Config) (*Client, error) {
vaultCfg := api.DefaultConfig()
vaultCfg.Address = cfg.Address
client, err := api.NewClient(vaultCfg)
if err != nil {
return nil, fmt.Errorf("failed to create vault client: %w", err)
}
vc := &Client{
client: client,
mountPath: cfg.MountPath,
secretPath: cfg.SecretPath,
}
// Authenticate based on method
switch strings.ToLower(cfg.AuthMethod) {
case "kubernetes":
if err := vc.authKubernetes(cfg.Role, cfg.TokenPath); err != nil {
return nil, fmt.Errorf("kubernetes auth failed: %w", err)
}
case "token":
// Token should be set via VAULT_TOKEN env var, which the API client reads automatically
if client.Token() == "" {
return nil, fmt.Errorf("VAULT_TOKEN environment variable not set")
}
default:
return nil, fmt.Errorf("unsupported auth method: %s", cfg.AuthMethod)
}
slog.Info("vault client authenticated",
"address", cfg.Address,
"auth_method", cfg.AuthMethod,
"mount_path", cfg.MountPath,
"secret_path", cfg.SecretPath,
)
return vc, nil
}
// authKubernetes authenticates using Kubernetes service account
func (c *Client) authKubernetes(role, tokenPath string) error {
if tokenPath == "" {
tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
}
// Verify token file exists
if _, err := os.Stat(tokenPath); err != nil {
return fmt.Errorf("service account token not found at %s: %w", tokenPath, err)
}
k8sAuth, err := kubernetes.NewKubernetesAuth(
role,
kubernetes.WithServiceAccountTokenPath(tokenPath),
)
if err != nil {
return fmt.Errorf("failed to create kubernetes auth: %w", err)
}
authInfo, err := c.client.Auth().Login(context.Background(), k8sAuth)
if err != nil {
return fmt.Errorf("failed to login with kubernetes auth: %w", err)
}
if authInfo == nil {
return fmt.Errorf("no auth info returned from vault")
}
slog.Info("authenticated to vault via kubernetes",
"role", role,
"token_ttl", authInfo.Auth.LeaseDuration,
)
return nil
}
// GetSecret retrieves a secret value by key
func (c *Client) GetSecret(ctx context.Context, key string) (string, error) {
c.mu.RLock()
secret := c.secret
c.mu.RUnlock()
// Fetch secret if not cached
if secret == nil {
if err := c.refreshSecret(ctx); err != nil {
return "", err
}
c.mu.RLock()
secret = c.secret
c.mu.RUnlock()
}
// KV v2 stores data under "data" key
data, ok := secret.Data["data"].(map[string]interface{})
if !ok {
// Try KV v1 format
data = secret.Data
}
value, ok := data[key]
if !ok {
return "", fmt.Errorf("key %q not found in secret", key)
}
str, ok := value.(string)
if !ok {
return "", fmt.Errorf("key %q is not a string", key)
}
return str, nil
}
// refreshSecret fetches the secret from Vault
func (c *Client) refreshSecret(ctx context.Context) error {
// Construct full path for KV v2: mount/data/path
fullPath := fmt.Sprintf("%s/data/%s", c.mountPath, c.secretPath)
secret, err := c.client.Logical().ReadWithContext(ctx, fullPath)
if err != nil {
return fmt.Errorf("failed to read secret: %w", err)
}
if secret == nil {
return fmt.Errorf("secret not found at %s", fullPath)
}
c.mu.Lock()
c.secret = secret
c.mu.Unlock()
slog.Debug("refreshed secret from vault", "path", fullPath)
return nil
}
// WatchAndRefresh periodically refreshes the secret and renews the auth token
func (c *Client) WatchAndRefresh(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.refreshSecret(ctx); err != nil {
slog.Error("failed to refresh secret from vault", "error", err)
} else {
slog.Debug("vault secret refreshed")
}
// Renew token if renewable
if c.client.Token() != "" {
if _, err := c.client.Auth().Token().RenewSelf(0); err != nil {
slog.Warn("failed to renew vault token", "error", err)
}
}
case <-ctx.Done():
return
}
}
}
// Close cleans up the client
func (c *Client) Close() error {
if c == nil || c.client == nil {
return nil
}
// Revoke token on shutdown (optional, but good practice)
if c.client.Token() != "" {
if err := c.client.Auth().Token().RevokeSelf(""); err != nil {
slog.Warn("failed to revoke vault token", "error", err)
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package vault
import (
"testing"
)
func TestConfig_Defaults(t *testing.T) {
cfg := Config{
Address: "http://vault.vault.svc.cluster.local:8200",
AuthMethod: "kubernetes",
Role: "ntfy-discord",
MountPath: "secret",
SecretPath: "ntfy-discord",
}
if cfg.Address == "" {
t.Error("Address should not be empty")
}
if cfg.AuthMethod != "kubernetes" {
t.Errorf("AuthMethod = %s, want kubernetes", cfg.AuthMethod)
}
if cfg.MountPath != "secret" {
t.Errorf("MountPath = %s, want secret", cfg.MountPath)
}
}
func TestNewClient_InvalidAddress(t *testing.T) {
cfg := Config{
Address: "not-a-valid-url",
AuthMethod: "kubernetes",
Role: "test",
MountPath: "secret",
SecretPath: "test",
}
// This should fail because there's no Kubernetes token
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for kubernetes auth without token")
}
}
func TestNewClient_TokenAuth_NoToken(t *testing.T) {
cfg := Config{
Address: "http://localhost:8200",
AuthMethod: "token",
MountPath: "secret",
SecretPath: "test",
}
// Should fail because VAULT_TOKEN is not set
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for token auth without VAULT_TOKEN")
}
}
func TestNewClient_UnsupportedAuthMethod(t *testing.T) {
cfg := Config{
Address: "http://localhost:8200",
AuthMethod: "unsupported",
MountPath: "secret",
SecretPath: "test",
}
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for unsupported auth method")
}
}
func TestClient_Close_Nil(t *testing.T) {
// Test that Close doesn't panic on a partially initialized client
c := &Client{}
// Should not panic
err := c.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestConfig_TokenPathDefault(t *testing.T) {
cfg := Config{
TokenPath: "",
}
// Default token path should be empty (will be set in authKubernetes)
if cfg.TokenPath != "" {
t.Errorf("TokenPath = %s, want empty", cfg.TokenPath)
}
}

59
main.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/bridge"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/config"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/server"
)
func main() {
// Setup structured logging
logLevel := slog.LevelInfo
if os.Getenv("LOG_LEVEL") == "debug" {
logLevel = slog.LevelDebug
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))
slog.SetDefault(logger)
slog.Info("starting ntfy-discord bridge")
// Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Load configuration (may connect to Vault)
cfg, err := config.Load(ctx)
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
defer cfg.Close()
// Start the bridge
b := bridge.New(cfg)
// Start HTTP server for health/metrics
srv := server.New(cfg.HTTPPort, b)
go srv.Start()
// Start watching secrets for hot reload (Vault and/or file-based)
cfg.WatchSecrets(ctx)
// Start the bridge
go b.Run(ctx)
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down")
cancel()
srv.Shutdown(ctx)
}