Compare commits

...

25 commits

Author SHA1 Message Date
d6e846e940 notification: wire notification system into app lifecycle
All checks were successful
continuous-integration/drone/push Build is passing
Configure notification senders (email, webhook, UnifiedPush) and
dispatcher in initUsecases(). Connect the execution callback from the
checker engine to the dispatcher. Pass notification dependencies
through to the API router.

Add NotificationRetentionDays config option (default 90 days).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:55:04 +07:00
de627353f6 notification: add API endpoints for channels, preferences, history, and acknowledgement
New endpoints under /api/notifications:
- CRUD for notification channels (email, webhook, UnifiedPush)
- CRUD for notification preferences (global, per-domain, per-service)
- Notification history listing
- Test notification endpoint

Acknowledgement endpoints added to scoped checker routes:
- POST /api/domains/:domain/checkers/:checkerId/acknowledge
- DELETE /api/domains/:domain/checkers/:checkerId/acknowledge

Thread NotificationController through route declarations for scoped
checker routes (domain and service level).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
53ae816523 checker: add execution callback for notification integration
Add ExecutionCallbackSetter interface and onComplete field to the
checker engine. After a successful execution, the callback is fired
asynchronously so it never blocks the checker pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
abd660ffb1 notification: add dispatcher with state tracking and acknowledgement
The Dispatcher is the core notification logic: it receives execution
callbacks, detects status transitions via persisted NotificationState,
resolves user preferences by specificity (service > domain > global),
respects quiet hours, and dispatches through configured channels.

Acknowledgement support allows users to suppress repeat notifications
until the next state change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
c3b5d3a97c notification: add channel senders for email, webhook, and UnifiedPush
Implement ChannelSender interface with three backends:
- EmailSender: reuses existing Mailer, sends Markdown-formatted alerts
- WebhookSender: HTTP POST with JSON payload and optional HMAC signature
- UnifiedPushSender: HTTP POST following the UnifiedPush protocol

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
73fb32e359 notification: add storage interfaces and KV implementations
Introduce NotificationChannelStorage, NotificationPreferenceStorage,
NotificationStateStorage, and NotificationRecordStorage interfaces
with KV-based implementations. Extend the Storage composite interface
and add migration 10 (no-op for KV prefix-based storage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
9f98b780ca notification: add notification models and error sentinels
Add NotificationChannel, NotificationPreference, NotificationState,
and NotificationRecord models for the upcoming notification system.
These models support email, webhook, and UnifiedPush channels with
per-user/domain/service preference scoping and state deduplication.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:54:32 +07:00
9472e7fe2e log: render happydns.Identifier values via .String() in log messages
Some checks are pending
continuous-integration/drone/push Build is pending
2026-04-25 21:50:57 +07:00
c411d6b4ea Handle 2 edge cases in database migration 2026-04-25 21:50:57 +07:00
47fd9cd066 tidy go mod 2026-04-25 21:50:57 +07:00
2a37c2db43 fix(deps): update module github.com/oracle/nosql-go-sdk to v1.4.8
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-22 20:11:35 +00:00
99f53084fb scheduler: extract serviceCheckerApplies helper
All checks were successful
continuous-integration/drone/push Build is passing
The predicate guarding service-checker auto-scheduling was duplicated
across buildQueue and two sites in NotifyDomainChange. Pull it into a
single helper so the rule lives in one place.
2026-04-22 16:09:13 +07:00
cd957a7667 scheduler: only auto-schedule service checkers with LimitToServices
Service-level checkers without LimitToServices no longer get enqueued
for every matching service: they must be activated explicitly via a
CheckPlan. Domain checkers and service checkers that declare a
LimitToServices whitelist keep their previous auto-discovery behavior.
2026-04-22 16:08:27 +07:00
fa7700355a backup: include core checker entities in backup/restore
All checks were successful
continuous-integration/drone/push Build is passing
Extend the admin backup to cover checker configurations, plans,
evaluations and executions — previously these were stored but silently
lost on restore. Add RestoreX storage methods so primary records keep
their original Id and secondary indexes are rebuilt (Create* generates
new IDs, Update* requires an existing record to clean stale indexes).
2026-04-22 12:45:45 +07:00
da1eb33faf tidy: add drop_invalid flag to delete undecodable records
Thread a dropInvalid bool through every TidyUpUseCase method and
expose it as a drop_invalid query parameter on POST /tidy (default
true). When set, Tidy deletes records that fail to decode — e.g.
legacy executions and evaluations whose CheckState.Status was stored
as a string before the SDK switched it to int — instead of leaving
them stuck in the store to log on every iteration.

Also reset KVIterator.err on exhaustion so a prior decode failure
does not surface as a spurious iteration error.
2026-04-22 12:45:45 +07:00
9ef5717f5b chore(deps): lock file maintenance
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-20 00:12:07 +00:00
0bee7695bc web: sort sessions by last used date in SessionsManager
All checks were successful
continuous-integration/drone/push Build is passing
Fixes: https://feedback.happydomain.org/posts/23/current-connection-sorted-list
2026-04-17 18:04:04 +07:00
a4f3595142 Use nodeJS workspace
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-17 12:58:17 +07:00
63bf11f1f5 CI: Generate a NOTICE file to keep dependancies licenses 2026-04-17 12:38:10 +07:00
a3aa74a7bf CI: Fix path to files to deploy with release
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/tag Build is passing
2026-04-17 11:17:56 +07:00
66d420b737 Fix WHOIS lookup not detecting non-existent .com domains
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
The whoisparser library does not return ErrNotFoundDomain for Verisign
"No match" responses — it parses them into a result with an empty
Domain field. Add a post-parse check to detect this case and return
ErrDomainDoesNotExist.
2026-04-17 09:55:39 +07:00
4f20a2ff06 Add Prometheus export documentation
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2026-04-16 17:08:05 +07:00
57de739f80 web: add Prometheus metrics URL link to checker config page 2026-04-16 17:08:05 +07:00
e857b1fb99 web-admin: wire dashboard to /metrics with collapsible details
Replaces the three REST count calls with a single Prometheus scrape that
auto-refreshes every 15s, surfaces queue/worker/in-flight/RSS/version/uptime
as featured cards, and tucks counters and Go runtime stats under a
"Show more metrics" Collapse.
2026-04-16 17:08:05 +07:00
504660367e checkers: add filter predicate to ListExecutionsBy* storage methods
Metrics endpoints now skip incomplete/planned executions by passing a
`doneExecution` filter so only fully-evaluated runs contribute to the
Prometheus output.
2026-04-16 17:08:01 +07:00
83 changed files with 3572 additions and 453 deletions

14
.drone-notice.tpl Normal file
View file

@ -0,0 +1,14 @@
happyDomain
===========
This product bundles third-party components under the following licenses.
The original license texts are reproduced in full below.
{{ range . }}
--------------------------------------------------------------------------------
Module: {{ .Name }}
License: {{ .LicenseName }}
{{ with .LicenseURL }}Source: {{ . }}
{{ end }}
{{ .LicenseText }}
{{ end }}

View file

@ -28,8 +28,8 @@ steps:
commands:
- apk --no-cache add tar
- yarn config set network-timeout 100000
- yarn --cwd web install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
- yarn install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./node_modules/.cache --exclude=./web/node_modules/.cache --exclude=./web-admin/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz ./package.json ./package-lock.json ./.npmrc ./node_modules/ ./web/ ./web-admin/
- mkdir deploy
- mv /dev/shm/happydomain-src.tar.gz deploy
- yarn --cwd web --offline run svelte-kit sync && yarn --cwd web --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-base/client.gen.ts
@ -37,14 +37,25 @@ steps:
- yarn --cwd web-admin --offline run svelte-kit sync && yarn --cwd web-admin --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-admin/client.gen.ts
- yarn --cwd web-admin --offline build
- name: frontend NOTICE
image: node:24-alpine
commands:
- npx --yes generate-license-file@3 --input web/package.json --output deploy/NOTICE.web --ci
- npx --yes generate-license-file@3 --input web-admin/package.json --output deploy/NOTICE.web-admin --ci
- for f in deploy/NOTICE.web deploy/NOTICE.web-admin; do { echo '------'; tail -n +3 "$f"; } > "$f.tmp" && mv "$f.tmp" "$f"; done
when:
event:
- tag
- name: backend-commit
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- go build -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
GOFLAGS: "-tags=netgo,swagger,web"
when:
event:
exclude:
@ -54,10 +65,16 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- go build -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
# Generate NOTICE file (merging Go + frontend)
- go install github.com/google/go-licenses@v1.6.0
- go-licenses report ./cmd/happyDomain --template .drone-notice.tpl > deploy/NOTICE.go
- cat deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin > deploy/NOTICE
- rm deploy/NOTICE.go deploy/NOTICE.web deploy/NOTICE.web-admin
environment:
CGO_ENABLED: 0
GOFLAGS: "-tags=netgo,swagger,web"
when:
event:
- tag
@ -154,11 +171,7 @@ steps:
from_secret: git_nemunaire_token
base_url: https://git.nemunai.re
draft: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -170,11 +183,7 @@ steps:
from_secret: codeberg_token
base_url: https://codeberg.org
draft: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -186,11 +195,7 @@ steps:
from_secret: github_release_token
draft: true
github_url: https://github.com
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
files: deploy/*
when:
event:
- tag
@ -232,8 +237,8 @@ steps:
- name: frontend
image: node:24-alpine
commands:
- cd web
- npm install --network-timeout=100000
- cd web
- npx svelte-kit sync && npm run generate:api
- npm test
- npm run build
@ -362,9 +367,7 @@ steps:
base_url: https://git.nemunai.re
draft: true
prerelease: true
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag
@ -377,9 +380,7 @@ steps:
base_url: https://codeberg.org
draft: true
prerelease: true
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag
@ -392,9 +393,7 @@ steps:
draft: true
prerelease: true
github_url: https://github.com
files:
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
files: deploy/*
when:
event:
- tag

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
./happydomain
./happyDomain
node_modules

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

275
docs/prometheus.md Normal file
View file

@ -0,0 +1,275 @@
# Scraping happyDomain checker metrics with Prometheus
happyDomain exposes check results as time-series metrics that Prometheus can
scrape directly. This lets you alert on DNS health checks, track trends over
time, and correlate domain health with the rest of your infrastructure.
> **Scope of this document:** user-facing metrics from the checker subsystem.
> The admin-socket `/metrics` endpoint (happyDomain internal instrumentation)
> is covered separately in [metrics.md](metrics.md).
---
## Getting started
### 1. Create an API token
The metrics endpoints require authentication. You need a long-lived API token
(not your login session).
1. Log in to happyDomain.
2. Go to the **Account** page (top-right user menu).
3. Scroll down to the **Security & Access** section.
4. Click **Create API key**.
5. Give the key a descriptive name (e.g. `prometheus-scraper`).
6. Click **Create API key** in the modal.
7. **Copy the secret immediately** (it is shown only once).
![Create API key modal showing the secret field](user-create-api-key.png)
The secret is used as a Bearer token in every request:
```
Authorization: Bearer <your-secret-here>
```
### 2. Verify manually
Before configuring Prometheus, confirm that the endpoint is reachable and
returns Prometheus-formatted data:
```bash
curl -s \
-H "Authorization: Bearer <your-secret>" \
-H "Accept: text/plain" \
https://happydomain.example.com/api/checkers/metrics
```
You should see lines like:
```
# HELP dns_rtt_seconds unit: s
# TYPE dns_rtt_seconds untyped
dns_rtt_seconds{...} 0.042 1744800000000
```
> **Format selection:** the API returns JSON by default. Add
> `Accept: text/plain` (or `Accept: application/openmetrics-text`) to receive
> Prometheus text exposition format 0.0.4. Prometheus itself sends the right
> header automatically when you use the `params` and `headers` scrape options
> shown below.
### 3. Minimal Prometheus scrape config
```yaml
scrape_configs:
- job_name: happydomain_checks
metrics_path: /api/checkers/metrics
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
params:
# Not needed; Prometheus sends Accept: application/openmetrics-text
# by default and the API honours it.
static_configs:
- targets:
- happydomain.example.com
```
That's it. Prometheus will now scrape all check metrics for your account on
every evaluation interval.
---
## Available endpoints
All endpoints are under `/api` and require `Authorization: Bearer <token>`.
### All user metrics
```
GET /api/checkers/metrics
```
Returns metrics from recent executions of **every checker across all your
domains**. This is the broadest scrape target, useful when you want a single
job covering everything.
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/checkers/metrics?limit=200"
```
---
### Domain metrics
```
GET /api/domains/{domain}/checkers/metrics
```
Returns metrics for **all checkers on a single domain**, including checkers
running on its services. `{domain}` is your domain FQDN (e.g.
`example.com`) or its internal identifier; both are accepted.
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/metrics"
```
Prometheus config for per-domain scraping:
```yaml
scrape_configs:
- job_name: happydomain_example_com
metrics_path: /api/domains/example.com/checkers/metrics
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
static_configs:
- targets:
- happydomain.example.com
```
![Checker configuration page with the Prometheus Metrics button highlighted](domain-prometheus-url.png)
---
### Per-checker metrics (domain level)
```
GET /api/domains/{domain}/checkers/{checkerId}/metrics
```
Returns metrics for **one specific checker on a domain**. Use this when you
want fine-grained scrape intervals or separate Prometheus jobs per checker
type.
`{checkerId}` is the checker identifier (e.g. `dnssec`, `mx_reachability`).
The easiest way to obtain the exact URL is the **Prometheus Metrics** button
on the checker configuration page (visible when the checker produces metrics).
| Query parameter | Default | Description |
|---|---|---|
| `limit` | `100` | Maximum number of recent executions to include. |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/metrics"
```
---
### Per-checker metrics (service level)
```
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/metrics
```
Returns metrics for **one specific checker on a DNS service** (a structured
record group, e.g. an MX configuration or an SPF record).
The URL for a service-level checker can be copied from the **Prometheus
Metrics** button on the service's checker configuration page.
| Path segment | Description |
|---|---|
| `{domain}` | Domain FQDN or identifier |
| `{zoneId}` | Zone snapshot identifier (opaque string) |
| `{subdomain}` | Relative owner name within the zone (e.g. `@`, `mail`) |
| `{serviceId}` | Service identifier (opaque string) |
| `{checkerId}` | Checker identifier |
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/zone/<zoneId>/@/services/<serviceId>/checkers/spf_policy/metrics"
```
---
### Single-execution metrics
```
GET /api/domains/{domain}/checkers/{checkerId}/executions/{executionId}/metrics
GET /api/domains/{domain}/zone/{zoneId}/{subdomain}/services/{serviceId}/checkers/{checkerId}/executions/{executionId}/metrics
```
Returns metrics from **one specific execution run**. This is not typically
used for Prometheus scraping (the data is historical and does not change after
the execution completes); it is more useful for debugging or one-off
inspections.
```bash
curl -s \
-H "Authorization: Bearer <token>" \
-H "Accept: text/plain" \
"https://happydomain.example.com/api/domains/example.com/checkers/dnssec/executions/<executionId>/metrics"
```
---
## Multi-domain Prometheus configuration
To scrape several domains with a single Prometheus job, use
`relabel_configs` to build the path dynamically from a label:
```yaml
scrape_configs:
- job_name: happydomain_domains
scheme: https
authorization:
type: Bearer
credentials: <your-secret>
metrics_path: /api/checkers/metrics # fallback; overridden per target
relabel_configs:
- source_labels: [__address__]
regex: (.+)@(.+)
target_label: __metrics_path__
replacement: /api/domains/$1/checkers/metrics
- source_labels: [__address__]
regex: .+@(.+)
target_label: __address__
replacement: $1
- source_labels: [domain]
target_label: domain
static_configs:
- targets:
- example.com@happydomain.example.com
- otherdomain.net@happydomain.example.com
labels:
instance: happydomain.example.com
```
Each target encodes `domain@host`; the relabel rules split them into the
correct `__metrics_path__` and `__address__`.
---
## Security considerations
- **Treat the API token like a password.** It grants read access to all check
metrics associated with your account.
- Use a dedicated token for each scraper. You can revoke individual tokens from
the **Security & Access** page without affecting your login session.
- Prefer HTTPS so the `Authorization` header is not transmitted in the clear.
- The metrics endpoints return only aggregated numeric values; they do not
expose DNS zone content, provider credentials, or other sensitive
configuration.

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

2
go.mod
View file

@ -27,7 +27,7 @@ require (
github.com/libdns/libdns v1.1.1
github.com/miekg/dns v1.1.72
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
github.com/oracle/nosql-go-sdk v1.4.8
github.com/ovh/go-ovh v1.9.0
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1

11
go.sum
View file

@ -135,6 +135,10 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@ -472,8 +476,8 @@ github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
github.com/oracle/nosql-go-sdk v1.4.7/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/nosql-go-sdk v1.4.8 h1:eMzz+yNLHvB0GCPAWxe0qYttBJF7Fh0Aup+zectpgc4=
github.com/oracle/nosql-go-sdk v1.4.8/go.mod h1:xgJE9wxADDbk7vR4FGA4NOt4RNAaIsQOj4sCATmCVXM=
github.com/oracle/oci-go-sdk/v65 v65.111.0 h1:eDkWg6ZN0uKwWzSekoFcQJhR+C+F/aVdTwr+lGHU9Qk=
github.com/oracle/oci-go-sdk/v65 v65.111.0/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
@ -629,6 +633,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
@ -648,7 +654,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=

View file

@ -120,6 +120,46 @@ func (bc *BackupController) DoBackup() (ret happydns.Backup) {
}
}
// Checker configurations (positional, one entry per (checker, user?, domain?, service?)).
if cfgIter, err := bc.store.ListAllCheckerConfigurations(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckerConfigurations: %s", err.Error()))
} else {
defer cfgIter.Close()
for cfgIter.Next() {
ret.CheckerConfigurations = append(ret.CheckerConfigurations, cfgIter.Item())
}
}
// Check plans.
if planIter, err := bc.store.ListAllCheckPlans(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckPlans: %s", err.Error()))
} else {
defer planIter.Close()
for planIter.Next() {
ret.CheckPlans = append(ret.CheckPlans, planIter.Item())
}
}
// Check evaluations.
if evalIter, err := bc.store.ListAllEvaluations(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve CheckEvaluations: %s", err.Error()))
} else {
defer evalIter.Close()
for evalIter.Next() {
ret.CheckEvaluations = append(ret.CheckEvaluations, evalIter.Item())
}
}
// Executions.
if execIter, err := bc.store.ListAllExecutions(); err != nil {
ret.Errors = append(ret.Errors, fmt.Sprintf("unable to retrieve Executions: %s", err.Error()))
} else {
defer execIter.Close()
for execIter.Next() {
ret.Executions = append(ret.Executions, execIter.Item())
}
}
return
}
@ -175,6 +215,29 @@ func (bc *BackupController) DoRestore(backup *happydns.Backup) (errs error) {
errs = errors.Join(errs, bc.store.UpdateSession(session))
}
// Checker configurations.
for _, cfg := range backup.CheckerConfigurations {
if cfg == nil {
continue
}
errs = errors.Join(errs, bc.store.UpdateCheckerConfiguration(cfg.CheckName, cfg.UserId, cfg.DomainId, cfg.ServiceId, cfg.Options))
}
// Check plans.
for _, plan := range backup.CheckPlans {
errs = errors.Join(errs, bc.store.RestoreCheckPlan(plan))
}
// Check evaluations (reference plans, restored above).
for _, eval := range backup.CheckEvaluations {
errs = errors.Join(errs, bc.store.RestoreEvaluation(eval))
}
// Executions.
for _, exec := range backup.Executions {
errs = errors.Join(errs, bc.store.RestoreExecution(exec))
}
return
}

View file

@ -22,6 +22,9 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
@ -41,14 +44,25 @@ func NewTidyController(tidyUpService happydns.TidyUpUseCase) *TidyController {
//
// @Summary Tidy up the database
// @Schemes
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage.
// @Description Performs cleanup and maintenance operations on the database, removing orphaned records and optimizing storage. When drop_invalid is true (default), records that fail to decode (e.g. after a schema change) are deleted; set it to false to only log them.
// @Tags admin
// @Accept json
// @Produce json
// @Param drop_invalid query bool false "Delete records that fail to decode instead of only logging (default true)"
// @Security securitydefinitions.basic
// @Success 200 {boolean} bool
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /tidy [post]
func (tc *TidyController) TidyDB(c *gin.Context) {
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll())
dropInvalid := true
if v := c.Query("drop_invalid"); v != "" {
parsed, err := strconv.ParseBool(v)
if err != nil {
happydns.ApiResponse(c, nil, fmt.Errorf("drop_invalid must be a boolean: %w", err))
return
}
dropInvalid = parsed
}
happydns.ApiResponse(c, true, tc.tidyUpService.TidyAll(dropInvalid))
}

View file

@ -244,7 +244,7 @@ func (cc *CheckerController) TriggerCheck(c *gin.Context) {
} else {
go func() {
if _, err := cc.engine.RunExecution(context.WithoutCancel(c.Request.Context()), exec, plan, req.Options); err != nil {
log.Printf("async RunExecution error for checker %q execution %v: %v", cname, exec.Id, err)
log.Printf("async RunExecution error for checker %q execution %s: %v", cname, exec.Id.String(), err)
}
}()
c.JSON(http.StatusAccepted, exec)

View file

@ -0,0 +1,514 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/model"
)
// NotificationController handles notification-related API endpoints.
type NotificationController struct {
dispatcher *notifUC.Dispatcher
channelStore notifUC.NotificationChannelStorage
prefStore notifUC.NotificationPreferenceStorage
recordStore notifUC.NotificationRecordStorage
}
// NewNotificationController creates a new NotificationController.
func NewNotificationController(
dispatcher *notifUC.Dispatcher,
channelStore notifUC.NotificationChannelStorage,
prefStore notifUC.NotificationPreferenceStorage,
recordStore notifUC.NotificationRecordStorage,
) *NotificationController {
return &NotificationController{
dispatcher: dispatcher,
channelStore: channelStore,
prefStore: prefStore,
recordStore: recordStore,
}
}
// --- Channel CRUD ---
// ListChannels returns all notification channels for the authenticated user.
//
// @Summary List notification channels
// @Tags notifications
// @Produce json
// @Success 200 {array} happydns.NotificationChannel
// @Router /notifications/channels [get]
func (nc *NotificationController) ListChannels(c *gin.Context) {
user := middleware.MyUser(c)
channels, err := nc.channelStore.ListChannelsByUser(user.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if channels == nil {
channels = []*happydns.NotificationChannel{}
}
c.JSON(http.StatusOK, channels)
}
// CreateChannel creates a new notification channel.
//
// @Summary Create a notification channel
// @Tags notifications
// @Accept json
// @Produce json
// @Param body body happydns.NotificationChannel true "Channel configuration"
// @Success 201 {object} happydns.NotificationChannel
// @Router /notifications/channels [post]
func (nc *NotificationController) CreateChannel(c *gin.Context) {
user := middleware.MyUser(c)
var ch happydns.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
ch.UserId = user.Id
if err := nc.channelStore.CreateChannel(&ch); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusCreated, ch)
}
// GetChannel returns a specific notification channel.
//
// @Summary Get a notification channel
// @Tags notifications
// @Produce json
// @Param channelId path string true "Channel ID"
// @Success 200 {object} happydns.NotificationChannel
// @Router /notifications/channels/{channelId} [get]
func (nc *NotificationController) GetChannel(c *gin.Context) {
user := middleware.MyUser(c)
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
return
}
ch, err := nc.channelStore.GetChannel(channelId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
if !ch.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
c.JSON(http.StatusOK, ch)
}
// UpdateChannel updates a notification channel.
//
// @Summary Update a notification channel
// @Tags notifications
// @Accept json
// @Produce json
// @Param channelId path string true "Channel ID"
// @Param body body happydns.NotificationChannel true "Channel configuration"
// @Success 200 {object} happydns.NotificationChannel
// @Router /notifications/channels/{channelId} [put]
func (nc *NotificationController) UpdateChannel(c *gin.Context) {
user := middleware.MyUser(c)
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
return
}
existing, err := nc.channelStore.GetChannel(channelId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
if !existing.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
var ch happydns.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
ch.Id = channelId
ch.UserId = user.Id
if err := nc.channelStore.UpdateChannel(&ch); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, ch)
}
// DeleteChannel deletes a notification channel.
//
// @Summary Delete a notification channel
// @Tags notifications
// @Param channelId path string true "Channel ID"
// @Success 204
// @Router /notifications/channels/{channelId} [delete]
func (nc *NotificationController) DeleteChannel(c *gin.Context) {
user := middleware.MyUser(c)
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
return
}
existing, err := nc.channelStore.GetChannel(channelId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
if !existing.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
if err := nc.channelStore.DeleteChannel(channelId); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// TestChannel sends a test notification through a channel.
//
// @Summary Send a test notification
// @Tags notifications
// @Param channelId path string true "Channel ID"
// @Success 200 {object} map[string]string
// @Router /notifications/channels/{channelId}/test [post]
func (nc *NotificationController) TestChannel(c *gin.Context) {
user := middleware.MyUser(c)
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
return
}
ch, err := nc.channelStore.GetChannel(channelId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
return
}
if !ch.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
if err := nc.dispatcher.SendTestNotification(ch, user); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// --- Preference CRUD ---
// ListPreferences returns all notification preferences for the authenticated user.
//
// @Summary List notification preferences
// @Tags notifications
// @Produce json
// @Success 200 {array} happydns.NotificationPreference
// @Router /notifications/preferences [get]
func (nc *NotificationController) ListPreferences(c *gin.Context) {
user := middleware.MyUser(c)
prefs, err := nc.prefStore.ListPreferencesByUser(user.Id)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if prefs == nil {
prefs = []*happydns.NotificationPreference{}
}
c.JSON(http.StatusOK, prefs)
}
// CreatePreference creates a new notification preference.
//
// @Summary Create a notification preference
// @Tags notifications
// @Accept json
// @Produce json
// @Param body body happydns.NotificationPreference true "Preference configuration"
// @Success 201 {object} happydns.NotificationPreference
// @Router /notifications/preferences [post]
func (nc *NotificationController) CreatePreference(c *gin.Context) {
user := middleware.MyUser(c)
var pref happydns.NotificationPreference
if err := c.ShouldBindJSON(&pref); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
pref.UserId = user.Id
if err := nc.prefStore.CreatePreference(&pref); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusCreated, pref)
}
// GetPreference returns a specific notification preference.
//
// @Summary Get a notification preference
// @Tags notifications
// @Produce json
// @Param prefId path string true "Preference ID"
// @Success 200 {object} happydns.NotificationPreference
// @Router /notifications/preferences/{prefId} [get]
func (nc *NotificationController) GetPreference(c *gin.Context) {
user := middleware.MyUser(c)
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
return
}
pref, err := nc.prefStore.GetPreference(prefId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
return
}
if !pref.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
c.JSON(http.StatusOK, pref)
}
// UpdatePreference updates a notification preference.
//
// @Summary Update a notification preference
// @Tags notifications
// @Accept json
// @Produce json
// @Param prefId path string true "Preference ID"
// @Param body body happydns.NotificationPreference true "Preference configuration"
// @Success 200 {object} happydns.NotificationPreference
// @Router /notifications/preferences/{prefId} [put]
func (nc *NotificationController) UpdatePreference(c *gin.Context) {
user := middleware.MyUser(c)
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
return
}
existing, err := nc.prefStore.GetPreference(prefId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
return
}
if !existing.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
var pref happydns.NotificationPreference
if err := c.ShouldBindJSON(&pref); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
pref.Id = prefId
pref.UserId = user.Id
if err := nc.prefStore.UpdatePreference(&pref); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, pref)
}
// DeletePreference deletes a notification preference.
//
// @Summary Delete a notification preference
// @Tags notifications
// @Param prefId path string true "Preference ID"
// @Success 204
// @Router /notifications/preferences/{prefId} [delete]
func (nc *NotificationController) DeletePreference(c *gin.Context) {
user := middleware.MyUser(c)
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
return
}
existing, err := nc.prefStore.GetPreference(prefId)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
return
}
if !existing.UserId.Equals(user.Id) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Access denied"})
return
}
if err := nc.prefStore.DeletePreference(prefId); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// --- History ---
// ListHistory returns recent notification records for the authenticated user.
//
// @Summary List notification history
// @Tags notifications
// @Produce json
// @Param limit query int false "Maximum number of records" default(50)
// @Success 200 {array} happydns.NotificationRecord
// @Router /notifications/history [get]
func (nc *NotificationController) ListHistory(c *gin.Context) {
user := middleware.MyUser(c)
limit := 50
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
records, err := nc.recordStore.ListRecordsByUser(user.Id, limit)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
if records == nil {
records = []*happydns.NotificationRecord{}
}
c.JSON(http.StatusOK, records)
}
// --- Acknowledgement ---
// AcknowledgeIssue marks a checker issue as acknowledged.
//
// @Summary Acknowledge a checker issue
// @Tags checkers
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param checkerId path string true "Checker ID"
// @Param body body happydns.AcknowledgeRequest true "Acknowledgement"
// @Success 200 {object} happydns.NotificationState
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [post]
func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
user := middleware.MyUser(c)
target := targetFromContext(c)
checkerID := c.Param("checkerId")
var req happydns.AcknowledgeRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Body is optional for acknowledgement.
req = happydns.AcknowledgeRequest{}
}
if err := nc.dispatcher.AcknowledgeIssue(user.Id, checkerID, target, user.Email, req.Annotation); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, state)
}
// ClearAcknowledgement removes an acknowledgement from a checker issue.
//
// @Summary Clear acknowledgement
// @Tags checkers
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param checkerId path string true "Checker ID"
// @Success 200 {object} happydns.NotificationState
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [delete]
func (nc *NotificationController) ClearAcknowledgement(c *gin.Context) {
user := middleware.MyUser(c)
target := targetFromContext(c)
checkerID := c.Param("checkerId")
if err := nc.dispatcher.ClearAcknowledgement(user.Id, checkerID, target); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, state)
}

View file

@ -75,7 +75,7 @@ func SameUserHandler(c *gin.Context) {
user := c.MustGet("user").(*happydns.User)
if !bytes.Equal(user.Id, myuser.Id) {
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id, user.Id)
log.Printf("%s: tries to do action as %s (logged %s)", c.ClientIP(), myuser.Id.String(), user.Id.String())
c.AbortWithStatusJSON(http.StatusForbidden, happydns.ErrorResponse{Message: "Not authorized"})
return
}

View file

@ -68,7 +68,8 @@ func DeclareCheckerRoutes(
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
// nc may be nil if the notification system is not configured.
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController, nc *controller.NotificationController) {
checkers := scopedRouter.Group("/checkers")
checkers.GET("", cc.ListAvailableChecks)
checkers.GET("/metrics", cc.GetDomainMetrics)
@ -113,4 +114,10 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch
// Results (under execution).
executionID.GET("/results", cc.GetExecutionResults)
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
// Acknowledgement (requires notification system).
if nc != nil {
checkerID.POST("/acknowledge", nc.AcknowledgeIssue)
checkerID.DELETE("/acknowledge", nc.ClearAcknowledgement)
}
}

View file

@ -43,6 +43,7 @@ func DeclareDomainRoutes(
cc *controller.CheckerController,
checkStatusUC *checkerUC.CheckStatusUsecase,
domainInfoUC happydns.DomainInfoUsecase,
nc *controller.NotificationController,
) {
dc := controller.NewDomainController(
domainUC,
@ -69,7 +70,7 @@ func DeclareDomainRoutes(
// Mount domain-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc, nc)
}
DeclareZoneRoutes(
@ -80,5 +81,6 @@ func DeclareDomainRoutes(
zoneServiceUC,
serviceUC,
cc,
nc,
)
}

View file

@ -0,0 +1,69 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
)
// DeclareNotificationRoutes registers notification routes under /api/notifications.
func DeclareNotificationRoutes(
apiAuthRoutes *gin.RouterGroup,
dispatcher *notifUC.Dispatcher,
channelStore notifUC.NotificationChannelStorage,
prefStore notifUC.NotificationPreferenceStorage,
recordStore notifUC.NotificationRecordStorage,
) *controller.NotificationController {
nc := controller.NewNotificationController(dispatcher, channelStore, prefStore, recordStore)
notif := apiAuthRoutes.Group("/notifications")
// Channels
channels := notif.Group("/channels")
channels.GET("", nc.ListChannels)
channels.POST("", nc.CreateChannel)
channelID := channels.Group("/:channelId")
channelID.GET("", nc.GetChannel)
channelID.PUT("", nc.UpdateChannel)
channelID.DELETE("", nc.DeleteChannel)
channelID.POST("/test", nc.TestChannel)
// Preferences
prefs := notif.Group("/preferences")
prefs.GET("", nc.ListPreferences)
prefs.POST("", nc.CreatePreference)
prefID := prefs.Group("/:prefId")
prefID.GET("", nc.GetPreference)
prefID.PUT("", nc.UpdatePreference)
prefID.DELETE("", nc.DeletePreference)
// History
notif.GET("/history", nc.ListHistory)
return nc
}

View file

@ -32,6 +32,7 @@ import (
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/internal/api/middleware"
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
happydns "git.happydns.org/happyDomain/model"
)
@ -66,6 +67,11 @@ type Dependencies struct {
PlannedProvider checkerUC.PlannedJobProvider
BudgetChecker checkerUC.BudgetChecker
CountManualTriggers bool
NotificationDispatcher *notifUC.Dispatcher
NotificationChannels notifUC.NotificationChannelStorage
NotificationPrefs notifUC.NotificationPreferenceStorage
NotificationRecords notifUC.NotificationRecordStorage
}
// @title happyDomain API
@ -152,6 +158,18 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
)
}
// Initialize notification controller if dispatcher is available.
var nc *controller.NotificationController
if dep.NotificationDispatcher != nil {
nc = DeclareNotificationRoutes(
apiAuthRoutes,
dep.NotificationDispatcher,
dep.NotificationChannels,
dep.NotificationPrefs,
dep.NotificationRecords,
)
}
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(
apiAuthRoutes,
@ -166,6 +184,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
cc,
dep.CheckStatusUC,
dep.DomainInfo,
nc,
)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)

View file

@ -37,6 +37,7 @@ func DeclareZoneServiceRoutes(
serviceUC happydns.ServiceUsecase,
zoneUC happydns.ZoneUsecase,
cc *controller.CheckerController,
nc *controller.NotificationController,
) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
@ -51,6 +52,6 @@ func DeclareZoneServiceRoutes(
// Mount service-scoped checker routes.
if cc != nil {
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc, nc)
}
}

View file

@ -38,6 +38,7 @@ func DeclareZoneRoutes(
zoneServiceUC happydns.ZoneServiceUsecase,
serviceUC happydns.ServiceUsecase,
cc *controller.CheckerController,
nc *controller.NotificationController,
) {
var checkStatusUC *checkerUC.CheckStatusUsecase
if cc != nil {
@ -74,6 +75,7 @@ func DeclareZoneRoutes(
serviceUC,
zoneUC,
cc,
nc,
)
apiZonesRoutes.POST("/records", zc.AddRecords)

View file

@ -35,6 +35,7 @@ import (
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/metrics"
"git.happydns.org/happyDomain/internal/newsletter"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
@ -42,6 +43,7 @@ import (
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
@ -81,6 +83,8 @@ type Usecases struct {
checkerScheduler *checkerUC.Scheduler
checkerJanitor *checkerUC.Janitor
checkerUserGater *checkerUC.UserGater
notificationDispatcher *notifUC.Dispatcher
}
type App struct {
@ -316,6 +320,29 @@ func (app *App) initUsecases() {
// Wire scheduler notifications for incremental queue updates.
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
// Notification system.
senders := map[happydns.NotificationChannelType]notifPkg.ChannelSender{
happydns.NotificationChannelEmail: notifPkg.NewEmailSender(app.mailer),
happydns.NotificationChannelWebhook: notifPkg.NewWebhookSender(),
happydns.NotificationChannelUnifiedPush: notifPkg.NewUnifiedPushSender(),
}
app.usecases.notificationDispatcher = notifUC.NewDispatcher(
app.store, // NotificationChannelStorage
app.store, // NotificationPreferenceStorage
app.store, // NotificationStateStorage
app.store, // NotificationRecordStorage
app.store, // UserGetter
app.store, // DomainGetter
senders,
app.cfg.ExternalURL.String(),
)
// Wire execution callback for notifications.
if setter, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
setter.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
}
}
func (app *App) setupRouter() {
@ -370,6 +397,11 @@ func (app *App) setupRouter() {
PlannedProvider: app.usecases.checkerScheduler,
BudgetChecker: app.usecases.checkerUserGater,
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
NotificationDispatcher: app.usecases.notificationDispatcher,
NotificationChannels: app.store,
NotificationPrefs: app.store,
NotificationRecords: app.store,
},
)
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)

View file

@ -127,6 +127,11 @@ func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error
return s.inner.CreateAuthUser(user)
}
func (s *instrumentedStorage) CreateChannel(ch *happydns.NotificationChannel) (err error) {
defer observe("create", "notification_channel")(&err)
return s.inner.CreateChannel(ch)
}
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("create", "check_plan")(&err)
return s.inner.CreateCheckPlan(plan)
@ -157,11 +162,21 @@ func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error
return s.inner.CreateOrUpdateUser(user)
}
func (s *instrumentedStorage) CreatePreference(pref *happydns.NotificationPreference) (err error) {
defer observe("create", "notification_preference")(&err)
return s.inner.CreatePreference(pref)
}
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
defer observe("create", "provider")(&err)
return s.inner.CreateProvider(prvd)
}
func (s *instrumentedStorage) CreateRecord(rec *happydns.NotificationRecord) (err error) {
defer observe("create", "notification_record")(&err)
return s.inner.CreateRecord(rec)
}
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
defer observe("create", "observation_snapshot")(&err)
return s.inner.CreateSnapshot(snap)
@ -177,6 +192,11 @@ func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error
return s.inner.DeleteAuthUser(user)
}
func (s *instrumentedStorage) DeleteChannel(channelId happydns.Identifier) (err error) {
defer observe("delete", "notification_channel")(&err)
return s.inner.DeleteChannel(channelId)
}
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
defer observe("delete", "check_plan")(&err)
return s.inner.DeleteCheckPlan(planID)
@ -217,11 +237,21 @@ func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target
return s.inner.DeleteExecutionsByChecker(checkerID, target)
}
func (s *instrumentedStorage) DeletePreference(prefId happydns.Identifier) (err error) {
defer observe("delete", "notification_preference")(&err)
return s.inner.DeletePreference(prefId)
}
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
defer observe("delete", "provider")(&err)
return s.inner.DeleteProvider(prvdid)
}
func (s *instrumentedStorage) DeleteRecordsOlderThan(before time.Time) (err error) {
defer observe("delete", "notification_record")(&err)
return s.inner.DeleteRecordsOlderThan(before)
}
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
defer observe("delete", "session")(&err)
return s.inner.DeleteSession(sessionid)
@ -232,6 +262,11 @@ func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err er
return s.inner.DeleteSnapshot(snapID)
}
func (s *instrumentedStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (err error) {
defer observe("delete", "notification_state")(&err)
return s.inner.DeleteState(checkerID, target, userId)
}
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
defer observe("delete", "user")(&err)
return s.inner.DeleteUser(userid)
@ -257,6 +292,11 @@ func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget,
return s.inner.GetCachedObservation(target, key)
}
func (s *instrumentedStorage) GetChannel(channelId happydns.Identifier) (ret *happydns.NotificationChannel, err error) {
defer observe("get", "notification_channel")(&err)
return s.inner.GetChannel(channelId)
}
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
defer observe("get", "check_plan")(&err)
return s.inner.GetCheckPlan(planID)
@ -297,6 +337,11 @@ func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (r
return s.inner.GetLatestEvaluation(planID)
}
func (s *instrumentedStorage) GetPreference(prefId happydns.Identifier) (ret *happydns.NotificationPreference, err error) {
defer observe("get", "notification_preference")(&err)
return s.inner.GetPreference(prefId)
}
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
defer observe("get", "provider")(&err)
return s.inner.GetProvider(prvdid)
@ -312,6 +357,11 @@ func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happ
return s.inner.GetSnapshot(snapID)
}
func (s *instrumentedStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (ret *happydns.NotificationState, err error) {
defer observe("get", "notification_state")(&err)
return s.inner.GetState(checkerID, target, userId)
}
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
defer observe("get", "user")(&err)
return s.inner.GetUser(userid)
@ -412,6 +462,11 @@ func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret
return s.inner.ListAuthUserSessions(user)
}
func (s *instrumentedStorage) ListChannelsByUser(userId happydns.Identifier) (ret []*happydns.NotificationChannel, err error) {
defer observe("list", "notification_channel")(&err)
return s.inner.ListChannelsByUser(userId)
}
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
defer observe("list", "check_plan")(&err)
return s.inner.ListCheckPlansByChecker(checkerID)
@ -452,14 +507,14 @@ func (s *instrumentedStorage) ListEvaluationsByPlan(planID happydns.Identifier)
return s.inner.ListEvaluationsByPlan(planID)
}
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) (ret []*happydns.Execution, err error) {
func (s *instrumentedStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByChecker(checkerID, target, limit)
return s.inner.ListExecutionsByChecker(checkerID, target, limit, filter)
}
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
func (s *instrumentedStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByDomain(domainId, limit)
return s.inner.ListExecutionsByDomain(domainId, limit, filter)
}
func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (ret []*happydns.Execution, err error) {
@ -467,9 +522,14 @@ func (s *instrumentedStorage) ListExecutionsByPlan(planID happydns.Identifier) (
return s.inner.ListExecutionsByPlan(planID)
}
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) (ret []*happydns.Execution, err error) {
func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) (ret []*happydns.Execution, err error) {
defer observe("list", "execution")(&err)
return s.inner.ListExecutionsByUser(userId, limit)
return s.inner.ListExecutionsByUser(userId, limit, filter)
}
func (s *instrumentedStorage) ListPreferencesByUser(userId happydns.Identifier) (ret []*happydns.NotificationPreference, err error) {
defer observe("list", "notification_preference")(&err)
return s.inner.ListPreferencesByUser(userId)
}
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
@ -477,6 +537,16 @@ func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.P
return s.inner.ListProviders(user)
}
func (s *instrumentedStorage) ListRecordsByUser(userId happydns.Identifier, limit int) (ret []*happydns.NotificationRecord, err error) {
defer observe("list", "notification_record")(&err)
return s.inner.ListRecordsByUser(userId, limit)
}
func (s *instrumentedStorage) ListStatesByUser(userId happydns.Identifier) (ret []*happydns.NotificationState, err error) {
defer observe("list", "notification_state")(&err)
return s.inner.ListStatesByUser(userId)
}
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
defer observe("list", "session")(&err)
return s.inner.ListUserSessions(userid)
@ -489,6 +559,26 @@ func (s *instrumentedStorage) PutCachedObservation(target happydns.CheckTarget,
return s.inner.PutCachedObservation(target, key, entry)
}
func (s *instrumentedStorage) RestoreCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("restore", "check_plan")(&err)
return s.inner.RestoreCheckPlan(plan)
}
func (s *instrumentedStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) (err error) {
defer observe("restore", "check_evaluation")(&err)
return s.inner.RestoreEvaluation(eval)
}
func (s *instrumentedStorage) RestoreExecution(exec *happydns.Execution) (err error) {
defer observe("restore", "execution")(&err)
return s.inner.RestoreExecution(exec)
}
func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) {
defer observe("put", "notification_state")(&err)
return s.inner.PutState(state)
}
func (s *instrumentedStorage) SchemaVersion() int { return s.inner.SchemaVersion() }
func (s *instrumentedStorage) SetLastSchedulerRun(t time.Time) (err error) {
@ -516,6 +606,11 @@ func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error
return s.inner.UpdateAuthUser(user)
}
func (s *instrumentedStorage) UpdateChannel(ch *happydns.NotificationChannel) (err error) {
defer observe("update", "notification_channel")(&err)
return s.inner.UpdateChannel(ch)
}
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
defer observe("update", "check_plan")(&err)
return s.inner.UpdateCheckPlan(plan)
@ -541,6 +636,11 @@ func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err err
return s.inner.UpdateExecution(exec)
}
func (s *instrumentedStorage) UpdatePreference(pref *happydns.NotificationPreference) (err error) {
defer observe("update", "notification_preference")(&err)
return s.inner.UpdatePreference(pref)
}
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
defer observe("update", "provider")(&err)
return s.inner.UpdateProvider(prvd)

View file

@ -53,6 +53,7 @@ func declareFlags(o *happydns.Options) {
flag.IntVar(&o.CheckerInactivityPauseDays, "checker-inactivity-pause-days", 90, "Pause checks for users that haven't logged in for this many days (0 disables, overridable per user)")
flag.IntVar(&o.CheckerMaxChecksPerDay, "checker-max-checks-per-day", 0, "System-wide default cap on scheduled checker executions per user per day; counter resets at 00:00 UTC and is in-memory only (0 = unlimited, overridable per user; see docs/checker-quotas.md)")
flag.BoolVar(&o.CheckerCountManualTriggers, "checker-count-manual-triggers", true, "When true (default), manual checker triggers count against UserQuota.MaxChecksPerDay and are refused with HTTP 429 once exhausted; when false, manual triggers bypass the quota entirely (see docs/checker-quotas.md)")
flag.IntVar(&o.NotificationRetentionDays, "notification-retention-days", 90, "How many days of notification history records to keep")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")

View file

@ -0,0 +1,80 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"fmt"
"net/mail"
"strings"
"git.happydns.org/happyDomain/model"
)
// EmailSender sends notifications via email using the existing Mailer.
type EmailSender struct {
mailer happydns.Mailer
}
// NewEmailSender creates a new EmailSender.
func NewEmailSender(mailer happydns.Mailer) *EmailSender {
return &EmailSender{mailer: mailer}
}
func (s *EmailSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
addr := channel.Config.EmailAddress
if addr == "" && payload.User != nil {
addr = payload.User.Email
}
if addr == "" {
return fmt.Errorf("no email address configured for channel %s", channel.Id)
}
to := &mail.Address{Address: addr}
if payload.User != nil {
to.Name = payload.User.Email
}
subject := fmt.Sprintf("[happyDomain] %s: %s", payload.DomainName, payload.NewStatus)
var body strings.Builder
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
fmt.Fprintf(&body, "**Domain:** %s\n\n", payload.DomainName)
fmt.Fprintf(&body, "**Checker:** %s\n\n", payload.CheckerID)
if len(payload.States) > 0 {
body.WriteString("### Rule Results\n\n")
for _, state := range payload.States {
fmt.Fprintf(&body, "- **%s** (%s): %s\n", state.Code, state.Status, state.Message)
}
body.WriteString("\n")
}
if payload.Annotation != "" {
fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation)
}
if payload.BaseURL != "" {
fmt.Fprintf(&body, "[View in happyDomain](%s)\n", payload.BaseURL)
}
return s.mailer.SendMail(to, subject, body.String())
}

View file

@ -0,0 +1,44 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"git.happydns.org/happyDomain/model"
)
// NotificationPayload holds the data passed to channel senders.
type NotificationPayload struct {
User *happydns.User
CheckerID string
Target happydns.CheckTarget
DomainName string
OldStatus happydns.Status
NewStatus happydns.Status
States []happydns.CheckState
Annotation string
BaseURL string
}
// ChannelSender sends a notification through a specific channel type.
type ChannelSender interface {
Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error
}

View file

@ -0,0 +1,90 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// UnifiedPushSender sends notifications via the UnifiedPush protocol.
type UnifiedPushSender struct {
client *http.Client
}
// NewUnifiedPushSender creates a new UnifiedPushSender.
func NewUnifiedPushSender() *UnifiedPushSender {
return &UnifiedPushSender{
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *UnifiedPushSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
if channel.Config.UnifiedPushEndpoint == "" {
return fmt.Errorf("no UnifiedPush endpoint configured for channel %s", channel.Id)
}
msg := WebhookPayload{
Event: "status_change",
Checker: payload.CheckerID,
Domain: payload.DomainName,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
States: payload.States,
Timestamp: time.Now(),
}
if payload.BaseURL != "" {
msg.DashboardURL = payload.BaseURL
}
body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshaling UnifiedPush payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, channel.Config.UnifiedPushEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating UnifiedPush request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sending UnifiedPush notification: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("UnifiedPush endpoint returned status %d", resp.StatusCode)
}
return nil
}

View file

@ -0,0 +1,118 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.happydns.org/happyDomain/model"
)
// WebhookPayload is the JSON body sent to webhook endpoints.
type WebhookPayload struct {
Event string `json:"event"`
Checker string `json:"checker"`
Domain string `json:"domain"`
Target happydns.CheckTarget `json:"target"`
OldStatus happydns.Status `json:"oldStatus"`
NewStatus happydns.Status `json:"newStatus"`
States []happydns.CheckState `json:"states,omitempty"`
Timestamp time.Time `json:"timestamp"`
DashboardURL string `json:"dashboardUrl,omitempty"`
}
// WebhookSender sends notifications via HTTP POST to a configured URL.
type WebhookSender struct {
client *http.Client
}
// NewWebhookSender creates a new WebhookSender.
func NewWebhookSender() *WebhookSender {
return &WebhookSender{
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *WebhookSender) Send(channel *happydns.NotificationChannel, payload *NotificationPayload) error {
if channel.Config.WebhookURL == "" {
return fmt.Errorf("no webhook URL configured for channel %s", channel.Id)
}
whPayload := WebhookPayload{
Event: "status_change",
Checker: payload.CheckerID,
Domain: payload.DomainName,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
States: payload.States,
Timestamp: time.Now(),
}
if payload.BaseURL != "" {
whPayload.DashboardURL = payload.BaseURL
}
body, err := json.Marshal(whPayload)
if err != nil {
return fmt.Errorf("marshaling webhook payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, channel.Config.WebhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
for k, v := range channel.Config.WebhookHeaders {
req.Header.Set(k, v)
}
if channel.Config.WebhookSecret != "" {
mac := hmac.New(sha256.New, []byte(channel.Config.WebhookSecret))
mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Happydomain-Signature", "sha256="+sig)
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sending webhook: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}

View file

@ -27,6 +27,7 @@ import (
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
"git.happydns.org/happyDomain/internal/usecase/notification"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/internal/usecase/user"
@ -51,6 +52,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
notification.NotificationChannelStorage
notification.NotificationPreferenceStorage
notification.NotificationStateStorage
notification.NotificationRecordStorage
provider.ProviderStorage
session.SessionStorage
user.UserStorage

View file

@ -73,6 +73,7 @@ func (s *KVStorage) ListEvaluationsByChecker(checkerID string, target happydns.C
s.GetEvaluation,
func(a, b *happydns.CheckEvaluation) bool { return a.EvaluatedAt.After(b.EvaluatedAt) },
limit,
nil,
)
}
@ -105,6 +106,24 @@ func (s *KVStorage) CreateEvaluation(eval *happydns.CheckEvaluation) error {
return nil
}
// RestoreEvaluation writes an evaluation at its existing Id and rebuilds
// its secondary indexes. Used by the backup restore path.
func (s *KVStorage) RestoreEvaluation(eval *happydns.CheckEvaluation) error {
if err := s.db.Put(fmt.Sprintf("chckeval|%s", eval.Id.String()), eval); err != nil {
return err
}
if eval.PlanID != nil {
indexKey := fmt.Sprintf("chckeval-plan|%s|%s", eval.PlanID.String(), eval.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
checkerIndexKey := fmt.Sprintf("chckeval-chkr|%s|%s|%s", eval.CheckerID, eval.Target.String(), eval.Id.String())
return s.db.Put(checkerIndexKey, true)
}
func (s *KVStorage) DeleteEvaluation(evalID happydns.Identifier) error {
// Load first to find plan ID for index cleanup.
eval, err := s.GetEvaluation(evalID)

View file

@ -138,6 +138,16 @@ func (s *KVStorage) putCheckPlanIndexes(plan *happydns.CheckPlan) error {
return nil
}
// RestoreCheckPlan writes a plan at its existing Id and (re)builds its
// secondary indexes. Used by the backup restore path, which must preserve
// the original identifier instead of generating a new one.
func (s *KVStorage) RestoreCheckPlan(plan *happydns.CheckPlan) error {
if err := s.db.Put(fmt.Sprintf("chckpln|%s", plan.Id.String()), plan); err != nil {
return err
}
return s.putCheckPlanIndexes(plan)
}
func (s *KVStorage) DeleteCheckPlan(planID happydns.Identifier) error {
plan, err := s.GetCheckPlan(planID)
if err != nil {

View file

@ -43,27 +43,28 @@ func (s *KVStorage) ListExecutionsByPlan(planID happydns.Identifier) ([]*happydn
}
// listRecentExecutions scans a prefix, decodes executions, sorts by most
// recent first, and applies an optional limit.
func (s *KVStorage) listRecentExecutions(prefix string, limit int) ([]*happydns.Execution, error) {
// recent first, applies an optional filter predicate, and then applies a limit.
func (s *KVStorage) listRecentExecutions(prefix string, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return listByIndexSorted(
s,
prefix,
s.GetExecution,
func(a, b *happydns.Execution) bool { return a.StartedAt.After(b.StartedAt) },
limit,
filter,
)
}
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit)
func (s *KVStorage) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-chkr|%s|%s|", checkerID, target.String()), limit, filter)
}
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit)
func (s *KVStorage) ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-user|%s|", userId.String()), limit, filter)
}
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit)
func (s *KVStorage) ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return s.listRecentExecutions(fmt.Sprintf("chckexec-domain|%s|", domainId.String()), limit, filter)
}
func (s *KVStorage) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
@ -122,6 +123,40 @@ func (s *KVStorage) CreateExecution(exec *happydns.Execution) error {
return nil
}
// RestoreExecution writes an execution at its existing Id and rebuilds
// its secondary indexes. Used by the backup restore path.
func (s *KVStorage) RestoreExecution(exec *happydns.Execution) error {
if err := s.db.Put(fmt.Sprintf("chckexec|%s", exec.Id.String()), exec); err != nil {
return err
}
if exec.PlanID != nil {
indexKey := fmt.Sprintf("chckexec-plan|%s|%s", exec.PlanID.String(), exec.Id.String())
if err := s.db.Put(indexKey, true); err != nil {
return err
}
}
checkerIndexKey := fmt.Sprintf("chckexec-chkr|%s|%s|%s", exec.CheckerID, exec.Target.String(), exec.Id.String())
if err := s.db.Put(checkerIndexKey, true); err != nil {
return err
}
if exec.Target.UserId != "" {
if err := s.db.Put(executionUserIndexKey(exec.Target.UserId, exec.Id.String()), true); err != nil {
return err
}
}
if exec.Target.DomainId != "" {
if err := s.db.Put(executionDomainIndexKey(exec.Target.DomainId, exec.Id.String()), true); err != nil {
return err
}
}
return nil
}
func (s *KVStorage) UpdateExecution(exec *happydns.Execution) error {
// Load the old record so we can detect changed index keys.
old, err := s.GetExecution(exec.Id)

View file

@ -76,6 +76,7 @@ func (it *KVIterator[T]) NextWithError() bool {
}
return true
}
it.err = nil
return false
}

View file

@ -0,0 +1,92 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) {
prefix := fmt.Sprintf("notifch-user|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var channels []*happydns.NotificationChannel
for iter.Next() {
var ch happydns.NotificationChannel
if err := s.db.DecodeData(iter.Value(), &ch); err != nil {
continue
}
channels = append(channels, &ch)
}
return channels, nil
}
func (s *KVStorage) GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) {
ch := &happydns.NotificationChannel{}
err := s.db.Get(fmt.Sprintf("notifch|%s", channelId.String()), ch)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationChannelNotFound
}
return ch, err
}
func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
key, id, err := s.db.FindIdentifierKey("notifch|")
if err != nil {
return err
}
ch.Id = id
if err := s.db.Put(key, ch); err != nil {
return err
}
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), ch.Id.String())
return s.db.Put(indexKey, ch)
}
func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error {
if err := s.db.Put(fmt.Sprintf("notifch|%s", ch.Id.String()), ch); err != nil {
return err
}
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), ch.Id.String())
return s.db.Put(indexKey, ch)
}
func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
ch, err := s.GetChannel(channelId)
if err != nil {
return err
}
indexKey := fmt.Sprintf("notifch-user|%s|%s", ch.UserId.String(), channelId.String())
if err := s.db.Delete(indexKey); err != nil {
return err
}
return s.db.Delete(fmt.Sprintf("notifch|%s", channelId.String()))
}

View file

@ -0,0 +1,92 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) {
prefix := fmt.Sprintf("notifpref-user|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var prefs []*happydns.NotificationPreference
for iter.Next() {
var pref happydns.NotificationPreference
if err := s.db.DecodeData(iter.Value(), &pref); err != nil {
continue
}
prefs = append(prefs, &pref)
}
return prefs, nil
}
func (s *KVStorage) GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) {
pref := &happydns.NotificationPreference{}
err := s.db.Get(fmt.Sprintf("notifpref|%s", prefId.String()), pref)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationPreferenceNotFound
}
return pref, err
}
func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) error {
key, id, err := s.db.FindIdentifierKey("notifpref|")
if err != nil {
return err
}
pref.Id = id
if err := s.db.Put(key, pref); err != nil {
return err
}
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), pref.Id.String())
return s.db.Put(indexKey, pref)
}
func (s *KVStorage) UpdatePreference(pref *happydns.NotificationPreference) error {
if err := s.db.Put(fmt.Sprintf("notifpref|%s", pref.Id.String()), pref); err != nil {
return err
}
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), pref.Id.String())
return s.db.Put(indexKey, pref)
}
func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
pref, err := s.GetPreference(prefId)
if err != nil {
return err
}
indexKey := fmt.Sprintf("notifpref-user|%s|%s", pref.UserId.String(), prefId.String())
if err := s.db.Delete(indexKey); err != nil {
return err
}
return s.db.Delete(fmt.Sprintf("notifpref|%s", prefId.String()))
}

View file

@ -0,0 +1,87 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"fmt"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) CreateRecord(rec *happydns.NotificationRecord) error {
key, id, err := s.db.FindIdentifierKey("notifrec|")
if err != nil {
return err
}
rec.Id = id
if err := s.db.Put(key, rec); err != nil {
return err
}
indexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
return s.db.Put(indexKey, rec)
}
func (s *KVStorage) ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) {
prefix := fmt.Sprintf("notifrec-user|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var records []*happydns.NotificationRecord
for iter.Next() {
var rec happydns.NotificationRecord
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
continue
}
records = append(records, &rec)
}
sort.Slice(records, func(i, j int) bool {
return records[i].SentAt.After(records[j].SentAt)
})
if limit > 0 && len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (s *KVStorage) DeleteRecordsOlderThan(before time.Time) error {
iter := s.db.Search("notifrec|")
defer iter.Release()
for iter.Next() {
var rec happydns.NotificationRecord
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
continue
}
if rec.SentAt.Before(before) {
_ = s.db.Delete(iter.Key())
userIndexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
_ = s.db.Delete(userIndexKey)
}
}
return nil
}

View file

@ -0,0 +1,66 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"git.happydns.org/happyDomain/model"
)
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
return fmt.Sprintf("notifstate|%s|%s|%s", userId.String(), checkerID, target.String())
}
func (s *KVStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) {
state := &happydns.NotificationState{}
err := s.db.Get(notifStateKey(checkerID, target, userId), state)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrNotificationStateNotFound
}
return state, err
}
func (s *KVStorage) PutState(state *happydns.NotificationState) error {
return s.db.Put(notifStateKey(state.CheckerID, state.Target, state.UserId), state)
}
func (s *KVStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error {
return s.db.Delete(notifStateKey(checkerID, target, userId))
}
func (s *KVStorage) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
prefix := fmt.Sprintf("notifstate|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var states []*happydns.NotificationState
for iter.Next() {
var state happydns.NotificationState
if err := s.db.DecodeData(iter.Value(), &state); err != nil {
continue
}
states = append(states, &state)
}
return states, nil
}

View file

@ -75,8 +75,10 @@ func listByIndex[T any](s *KVStorage, prefix string, getEntity func(happydns.Ide
return results, nil
}
// listByIndexSorted is like listByIndex but sorts results and applies a limit.
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int) ([]*T, error) {
// listByIndexSorted is like listByIndex but sorts results, applies an optional
// filter predicate, and then applies a limit. The limit counts only items that
// pass the filter; passing nil for filter disables filtering.
func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happydns.Identifier) (*T, error), less func(*T, *T) bool, limit int, filter func(*T) bool) ([]*T, error) {
results, err := listByIndex(s, prefix, getEntity)
if err != nil {
return nil, err
@ -86,10 +88,23 @@ func listByIndexSorted[T any](s *KVStorage, prefix string, getEntity func(happyd
return less(results[i], results[j])
})
if limit > 0 && len(results) > limit {
results = results[:limit]
if filter == nil {
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
return results, nil
filtered := results[:0]
for _, r := range results {
if filter(r) {
filtered = append(filtered, r)
if limit > 0 && len(filtered) >= limit {
break
}
}
}
return filtered, nil
}
// tidyTwoPartIndex removes stale secondary index entries of the form

View file

@ -0,0 +1,28 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
// migrateFrom10 adds the notification system tables.
// KV storage uses prefix-based keys, so no structural migration is needed.
func migrateFrom10(s *KVStorage) error {
return nil
}

View file

@ -38,12 +38,12 @@ import (
// abstract.EMail
func explodeAbstractEMail(dn happydns.Subdomain, in *happydns.ServiceMessage) ([]*happydns.ServiceMessage, error) {
var val struct {
MX []map[string]any `json:"mx,omitempty"`
SPF map[string]any `json:"spf,omitempty"`
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
MX []map[string]any `json:"mx,omitempty"`
SPF map[string]any `json:"spf,omitempty"`
DKIM map[string]*svcs.DKIM `json:"dkim,omitempty"`
DMARC *svcs.DMARCFields `json:"dmarc,omitempty"`
MTA_STS *svcs.MTASTSFields `json:"mta_sts,omitempty"`
TLS_RPT *svcs.TLS_RPTField `json:"tls_rpt,omitempty"`
}
err := json.Unmarshal(in.Service, &val)
@ -815,7 +815,12 @@ func migrateFrom7(s *KVStorage) error {
return nil, err
}
rr, err := dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
var rr dns.RR
if strings.Contains(val["Target"], "IN\tCNAME") {
rr, err = dns.NewRR(val["Target"])
} else {
rr, err = dns.NewRR(fmt.Sprintf("%s.zZzZ. 0 IN CNAME %s", val["SubDomain"], helpers.DomainFQDN(val["Target"], "zZzZ.")))
}
if err != nil {
return nil, err
}

View file

@ -22,6 +22,7 @@
package database
import (
"fmt"
"log"
)
@ -33,6 +34,15 @@ func migrateFrom9(s *KVStorage) (err error) {
for sessions.Next() {
session := sessions.Item()
if len(session.Id) != 103 {
err = sessions.DropItem()
if err != nil {
return fmt.Errorf("unable to drop invalid session: %s: %w", session.Id, err)
}
log.Printf("Drop invalid session identifier: %s", session.Id)
continue
}
err := s.UpdateSession(session)
if err != nil {
return err

View file

@ -39,6 +39,7 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
migrateFrom7,
migrateFrom8,
migrateFrom9,
migrateFrom10,
}
type Version struct {

View file

@ -374,6 +374,11 @@ func (s *planStore) UpdateCheckPlan(plan *happydns.CheckPlan) error {
return nil
}
func (s *planStore) RestoreCheckPlan(plan *happydns.CheckPlan) error {
s.plans[plan.Id.String()] = plan
return nil
}
func (s *planStore) DeleteCheckPlan(planID happydns.Identifier) error {
delete(s.plans, planID.String())
return nil

View file

@ -160,7 +160,7 @@ func (u *CheckStatusUsecase) ListCheckerStatuses(target happydns.CheckTarget) ([
}
status.EnabledRules = enabledRules
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1)
execs, err := u.execStore.ListExecutionsByChecker(def.ID, target, 1, nil)
if err != nil {
log.Printf("ListCheckerStatuses: failed to fetch latest execution for checker %s: %v", def.ID, err)
} else if len(execs) > 0 {
@ -190,7 +190,7 @@ func (u *CheckStatusUsecase) GetExecution(scope happydns.CheckTarget, execID hap
// ListExecutionsByChecker returns executions for a checker on a target, up to limit.
func (u *CheckStatusUsecase) ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error) {
return u.execStore.ListExecutionsByChecker(checkerID, target, limit)
return u.execStore.ListExecutionsByChecker(checkerID, target, limit, nil)
}
// GetObservationsByExecution returns the observation snapshot for an execution after verifying scope.
@ -263,7 +263,7 @@ func worstStatuses(execs []*happydns.Execution, keyFn func(*happydns.Execution)
// (most critical) status per domain. It keeps only the latest execution per
// (domain, checker) pair and reports the worst status among them.
func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs)
execs, err := u.execStore.ListExecutionsByUser(userId, worstStatusMaxExecs, nil)
if err != nil {
return nil, err
}
@ -276,7 +276,7 @@ func (u *CheckStatusUsecase) GetWorstDomainStatuses(userId happydns.Identifier)
// It fetches all executions for the domain in a single query, then aggregates
// the worst status per service in memory.
func (u *CheckStatusUsecase) GetWorstServiceStatuses(userId happydns.Identifier, domainId happydns.Identifier) (map[string]*happydns.Status, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs)
execs, err := u.execStore.ListExecutionsByDomain(domainId, worstStatusMaxExecs, nil)
if err != nil {
return nil, err
}
@ -342,6 +342,12 @@ func (u *CheckStatusUsecase) extractMetricsFromExecutions(execs []*happydns.Exec
return allMetrics, nil
}
// doneExecution is a filter predicate for ListExecutionsBy* that keeps only
// executions that have completed successfully and can produce metrics.
func doneExecution(e *happydns.Execution) bool {
return e.Status == happydns.ExecutionDone && e.EvaluationID != nil
}
// GetMetricsByExecution extracts metrics from a single execution's snapshot after verifying scope.
func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, execID happydns.Identifier) ([]happydns.CheckMetric, error) {
exec, err := u.execStore.GetExecution(execID)
@ -356,7 +362,7 @@ func (u *CheckStatusUsecase) GetMetricsByExecution(scope happydns.CheckTarget, e
// GetMetricsByChecker extracts metrics from recent executions of a checker on a target.
func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit)
execs, err := u.execStore.ListExecutionsByChecker(checkerID, target, limit, doneExecution)
if err != nil {
return nil, err
}
@ -365,7 +371,7 @@ func (u *CheckStatusUsecase) GetMetricsByChecker(checkerID string, target happyd
// GetMetricsByUser extracts metrics from recent executions for a user across all checkers.
func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByUser(userId, limit)
execs, err := u.execStore.ListExecutionsByUser(userId, limit, doneExecution)
if err != nil {
return nil, err
}
@ -374,7 +380,7 @@ func (u *CheckStatusUsecase) GetMetricsByUser(userId happydns.Identifier, limit
// GetMetricsByDomain extracts metrics from recent executions for a domain (including services).
func (u *CheckStatusUsecase) GetMetricsByDomain(domainId happydns.Identifier, limit int) ([]happydns.CheckMetric, error) {
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit)
execs, err := u.execStore.ListExecutionsByDomain(domainId, limit, doneExecution)
if err != nil {
return nil, err
}

View file

@ -32,6 +32,12 @@ import (
"git.happydns.org/happyDomain/model"
)
// ExecutionCallbackSetter is implemented by checker engines that support
// notification callbacks after execution completion.
type ExecutionCallbackSetter interface {
SetExecutionCallback(func(*happydns.Execution, *happydns.CheckEvaluation))
}
// checkerEngine implements the happydns.CheckerEngine interface.
type checkerEngine struct {
optionsUC *CheckerOptionsUsecase
@ -39,6 +45,13 @@ type checkerEngine struct {
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
cacheStore ObservationCacheStorage
onComplete func(exec *happydns.Execution, eval *happydns.CheckEvaluation)
}
// SetExecutionCallback registers a callback invoked after each successful execution.
func (e *checkerEngine) SetExecutionCallback(cb func(*happydns.Execution, *happydns.CheckEvaluation)) {
e.onComplete = cb
}
// NewCheckerEngine creates a new CheckerEngine implementation.
@ -135,6 +148,12 @@ func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Executi
log.Printf("CheckerEngine: failed to update execution: %v", err)
}
// Fire notification callback (runs synchronously within the caller's goroutine
// so that shutdown can wait for it to complete).
if e.onComplete != nil {
e.onComplete(exec, eval)
}
return eval, nil
}

View file

@ -111,7 +111,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
}
// Verify execution was persisted.
execs, err := store.ListExecutionsByChecker("test_checker", target, 0)
execs, err := store.ListExecutionsByChecker("test_checker", target, 0, nil)
if err != nil {
t.Fatalf("ListExecutionsByChecker() returned error: %v", err)
}

View file

@ -88,13 +88,13 @@ func (s *mockExecStore) DeleteExecution(execID happydns.Identifier) error {
func (s *mockExecStore) ListAllExecutions() (happydns.Iterator[happydns.Execution], error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int) ([]*happydns.Execution, error) {
func (s *mockExecStore) ListExecutionsByChecker(string, happydns.CheckTarget, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int) ([]*happydns.Execution, error) {
func (s *mockExecStore) ListExecutionsByUser(happydns.Identifier, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int) ([]*happydns.Execution, error) {
func (s *mockExecStore) ListExecutionsByDomain(happydns.Identifier, int, func(*happydns.Execution) bool) ([]*happydns.Execution, error) {
return nil, nil
}
func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution, error) {
@ -102,6 +102,7 @@ func (s *mockExecStore) GetExecution(happydns.Identifier) (*happydns.Execution,
}
func (s *mockExecStore) CreateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) UpdateExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) RestoreExecution(*happydns.Execution) error { return nil }
func (s *mockExecStore) DeleteExecutionsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockExecStore) TidyExecutionIndexes() error { return nil }
func (s *mockExecStore) ClearExecutions() error { return nil }
@ -492,6 +493,7 @@ func (s *mockEvalStore) GetLatestEvaluation(happydns.Identifier) (*happydns.Chec
return nil, nil
}
func (s *mockEvalStore) CreateEvaluation(*happydns.CheckEvaluation) error { return nil }
func (s *mockEvalStore) RestoreEvaluation(*happydns.CheckEvaluation) error { return nil }
func (s *mockEvalStore) DeleteEvaluationsByChecker(string, happydns.CheckTarget) error { return nil }
func (s *mockEvalStore) TidyEvaluationIndexes() error { return nil }
func (s *mockEvalStore) ClearEvaluations() error { return nil }

View file

@ -492,7 +492,7 @@ func (s *Scheduler) buildQueue() {
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: did.String(), ServiceId: sid.String(), ServiceType: svc.Type}
for _, c := range serviceCheckers {
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(c.def, svc.Type) {
continue
}
s.enqueueJob(c.id, c.def, svcTarget, disabledSet, planMap, lastRun)
@ -534,7 +534,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
}
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(def, svc.Type) {
continue
}
svcTarget := happydns.CheckTarget{UserId: uid.String(), DomainId: didStr, ServiceId: svc.Id.String(), ServiceType: svc.Type}
@ -579,7 +579,7 @@ func (s *Scheduler) NotifyDomainChange(domain *happydns.Domain) {
if def.Availability.ApplyToService {
for _, svc := range services {
if len(def.Availability.LimitToServices) > 0 && !slices.Contains(def.Availability.LimitToServices, svc.Type) {
if !serviceCheckerApplies(def, svc.Type) {
continue
}
sid := svc.Id
@ -627,10 +627,20 @@ func (s *Scheduler) NotifyDomainRemoved(domainID happydns.Identifier) {
s.mu.Unlock()
if n > 0 {
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID, n)
log.Printf("Scheduler: NotifyDomainRemoved(%s): removed %d jobs", domainID.String(), n)
}
}
// serviceCheckerApplies reports whether a service-scoped checker should be
// auto-scheduled for the given service type. Auto-scheduling is restricted to
// checkers that explicitly declare the service type in LimitToServices; a
// checker with an empty LimitToServices must be activated manually via a
// CheckPlan.
func serviceCheckerApplies(def *happydns.CheckerDefinition, serviceType string) bool {
return len(def.Availability.LimitToServices) > 0 &&
slices.Contains(def.Availability.LimitToServices, serviceType)
}
// buildPlanIndex builds disabled and plan lookup maps from a slice of plans.
func buildPlanIndex(plans []*happydns.CheckPlan) (disabledSet map[string]bool, planMap map[string]*happydns.CheckPlan) {
disabledSet = make(map[string]bool)
@ -731,7 +741,7 @@ func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.Serv
}
zone, err := s.zoneStore.GetZone(domain.ZoneHistory[idx])
if err != nil {
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx], domain.DomainName, err)
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", domain.ZoneHistory[idx].String(), domain.DomainName, err)
continue
}
for _, svcs := range zone.Services {

View file

@ -121,8 +121,9 @@ func (s *mockPlanStore) CreateCheckPlan(plan *happydns.CheckPlan) error {
s.plans = append(s.plans, plan)
return nil
}
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) UpdateCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) RestoreCheckPlan(plan *happydns.CheckPlan) error { return nil }
func (s *mockPlanStore) DeleteCheckPlan(happydns.Identifier) error { return nil }
func (s *mockPlanStore) TidyCheckPlanIndexes() error { return nil }
func (s *mockPlanStore) ClearCheckPlans() error { return nil }

View file

@ -61,6 +61,7 @@ type CheckPlanStorage interface {
GetCheckPlan(planID happydns.Identifier) (*happydns.CheckPlan, error)
CreateCheckPlan(plan *happydns.CheckPlan) error
UpdateCheckPlan(plan *happydns.CheckPlan) error
RestoreCheckPlan(plan *happydns.CheckPlan) error
DeleteCheckPlan(planID happydns.Identifier) error
TidyCheckPlanIndexes() error
ClearCheckPlans() error
@ -84,6 +85,7 @@ type CheckEvaluationStorage interface {
GetEvaluation(evalID happydns.Identifier) (*happydns.CheckEvaluation, error)
GetLatestEvaluation(planID happydns.Identifier) (*happydns.CheckEvaluation, error)
CreateEvaluation(eval *happydns.CheckEvaluation) error
RestoreEvaluation(eval *happydns.CheckEvaluation) error
DeleteEvaluation(evalID happydns.Identifier) error
DeleteEvaluationsByChecker(checkerID string, target happydns.CheckTarget) error
TidyEvaluationIndexes() error
@ -94,12 +96,13 @@ type CheckEvaluationStorage interface {
type ExecutionStorage interface {
ListAllExecutions() (happydns.Iterator[happydns.Execution], error)
ListExecutionsByPlan(planID happydns.Identifier) ([]*happydns.Execution, error)
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int) ([]*happydns.Execution, error)
ListExecutionsByUser(userId happydns.Identifier, limit int) ([]*happydns.Execution, error)
ListExecutionsByDomain(domainId happydns.Identifier, limit int) ([]*happydns.Execution, error)
ListExecutionsByChecker(checkerID string, target happydns.CheckTarget, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
ListExecutionsByUser(userId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
ListExecutionsByDomain(domainId happydns.Identifier, limit int, filter func(*happydns.Execution) bool) ([]*happydns.Execution, error)
GetExecution(execID happydns.Identifier) (*happydns.Execution, error)
CreateExecution(exec *happydns.Execution) error
UpdateExecution(exec *happydns.Execution) error
RestoreExecution(exec *happydns.Execution) error
DeleteExecution(execID happydns.Identifier) error
DeleteExecutionsByChecker(checkerID string, target happydns.CheckTarget) error
TidyExecutionIndexes() error

View file

@ -0,0 +1,79 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"errors"
"time"
"git.happydns.org/happyDomain/model"
)
// AcknowledgeIssue marks an issue as acknowledged, suppressing repeat
// notifications until the next state change.
func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
state, err := d.stateStore.GetState(checkerID, target, userId)
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
// Create a new state if one doesn't exist yet.
state = &happydns.NotificationState{
CheckerID: checkerID,
Target: target,
UserId: userId,
LastStatus: happydns.StatusUnknown,
}
} else if err != nil {
return err
}
now := time.Now()
state.Acknowledged = true
state.AcknowledgedAt = &now
state.AcknowledgedBy = acknowledgedBy
state.Annotation = annotation
return d.stateStore.PutState(state)
}
// ClearAcknowledgement removes the acknowledgement from an issue.
func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
state, err := d.stateStore.GetState(checkerID, target, userId)
if err != nil {
return err
}
state.Acknowledged = false
state.AcknowledgedAt = nil
state.AcknowledgedBy = ""
state.Annotation = ""
return d.stateStore.PutState(state)
}
// GetState returns the current notification state for a checker/target/user.
func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
return d.stateStore.GetState(checkerID, target, userId)
}
// ListStatesByUser returns all notification states for a user.
func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
return d.stateStore.ListStatesByUser(userId)
}

View file

@ -0,0 +1,312 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"errors"
"fmt"
"log"
"time"
notifPkg "git.happydns.org/happyDomain/internal/notification"
"git.happydns.org/happyDomain/model"
)
// Dispatcher evaluates notification state transitions after checker executions
// and dispatches notifications through configured channels.
type Dispatcher struct {
channelStore NotificationChannelStorage
prefStore NotificationPreferenceStorage
stateStore NotificationStateStorage
recordStore NotificationRecordStorage
userStore UserGetter
domainStore DomainGetter
senders map[happydns.NotificationChannelType]notifPkg.ChannelSender
baseURL string
}
// NewDispatcher creates a new notification Dispatcher.
func NewDispatcher(
channelStore NotificationChannelStorage,
prefStore NotificationPreferenceStorage,
stateStore NotificationStateStorage,
recordStore NotificationRecordStorage,
userStore UserGetter,
domainStore DomainGetter,
senders map[happydns.NotificationChannelType]notifPkg.ChannelSender,
baseURL string,
) *Dispatcher {
return &Dispatcher{
channelStore: channelStore,
prefStore: prefStore,
stateStore: stateStore,
recordStore: recordStore,
userStore: userStore,
domainStore: domainStore,
senders: senders,
baseURL: baseURL,
}
}
// OnExecutionComplete is the callback invoked after a checker execution finishes.
// It determines whether a notification should be sent based on state transitions,
// user preferences, acknowledgements, and quiet hours.
func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) {
if exec == nil || exec.Status != happydns.ExecutionDone {
return
}
userId := happydns.TargetIdentifier(exec.Target.UserId)
if userId == nil {
return
}
user, err := d.userStore.GetUser(*userId)
if err != nil {
log.Printf("notification: failed to load user %s: %v", exec.Target.UserId, err)
return
}
newStatus := exec.Result.Status
// Load or create notification state.
state, err := d.stateStore.GetState(exec.CheckerID, exec.Target, *userId)
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
state = &happydns.NotificationState{
CheckerID: exec.CheckerID,
Target: exec.Target,
UserId: *userId,
LastStatus: happydns.StatusUnknown,
}
} else if err != nil {
log.Printf("notification: failed to load state for %s/%s: %v", exec.CheckerID, exec.Target.String(), err)
return
}
oldStatus := state.LastStatus
// No state transition: skip notification.
if oldStatus == newStatus {
return
}
// Clear acknowledgement on any state change.
state.Acknowledged = false
state.AcknowledgedAt = nil
state.AcknowledgedBy = ""
state.Annotation = ""
isRecovery := newStatus < happydns.StatusWarn && oldStatus >= happydns.StatusWarn
// Resolve the effective preference for this target.
pref := d.resolvePreference(user, exec.Target)
if pref == nil || !pref.Enabled {
// No preference or disabled: still update state, but don't notify.
d.updateState(state, newStatus)
return
}
// Check minimum severity threshold.
if !isRecovery && newStatus < pref.MinStatus {
d.updateState(state, newStatus)
return
}
// Check recovery notification preference.
if isRecovery && !pref.NotifyRecovery {
d.updateState(state, newStatus)
return
}
// Check quiet hours.
if d.isQuietHour(pref) {
d.updateState(state, newStatus)
return
}
// Resolve domain name for the notification payload.
domainName := exec.Target.DomainId
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
if domain, err := d.domainStore.GetDomain(*did); err == nil {
domainName = domain.DomainName
}
}
var states []happydns.CheckState
if eval != nil {
states = eval.States
}
payload := &notifPkg.NotificationPayload{
User: user,
CheckerID: exec.CheckerID,
Target: exec.Target,
DomainName: domainName,
OldStatus: oldStatus,
NewStatus: newStatus,
States: states,
BaseURL: d.baseURL,
}
// Resolve channels and send.
channels := d.resolveChannels(user, pref)
for _, ch := range channels {
d.sendAndRecord(ch, payload, user)
}
d.updateState(state, newStatus)
}
// resolvePreference finds the most specific enabled preference for the given target.
// Specificity: service > domain > global.
func (d *Dispatcher) resolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference {
prefs, err := d.prefStore.ListPreferencesByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load preferences for user %s: %v", user.Id, err)
return nil
}
var global, domainMatch, serviceMatch *happydns.NotificationPreference
for _, p := range prefs {
if p.ServiceId != nil && p.ServiceId.String() == target.ServiceId {
serviceMatch = p
} else if p.DomainId != nil && p.DomainId.String() == target.DomainId && p.ServiceId == nil {
domainMatch = p
} else if p.DomainId == nil && p.ServiceId == nil {
global = p
}
}
if serviceMatch != nil {
return serviceMatch
}
if domainMatch != nil {
return domainMatch
}
return global
}
// resolveChannels returns the channels to use for a notification.
func (d *Dispatcher) resolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel {
allChannels, err := d.channelStore.ListChannelsByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load channels for user %s: %v", user.Id, err)
return nil
}
// Build a set of allowed channel IDs from the preference.
var allowed map[string]bool
if len(pref.ChannelIds) > 0 {
allowed = make(map[string]bool, len(pref.ChannelIds))
for _, id := range pref.ChannelIds {
allowed[id.String()] = true
}
}
var result []*happydns.NotificationChannel
for _, ch := range allChannels {
if !ch.Enabled {
continue
}
if allowed != nil && !allowed[ch.Id.String()] {
continue
}
result = append(result, ch)
}
return result
}
// isQuietHour returns true if the current UTC hour falls within the quiet window.
func (d *Dispatcher) isQuietHour(pref *happydns.NotificationPreference) bool {
if pref.QuietStart == nil || pref.QuietEnd == nil {
return false
}
hour := time.Now().UTC().Hour()
start := *pref.QuietStart
end := *pref.QuietEnd
if start <= end {
return hour >= start && hour < end
}
// Wraps midnight, e.g. 22:00 - 06:00.
return hour >= start || hour < end
}
// sendAndRecord dispatches through the appropriate sender and logs the result.
func (d *Dispatcher) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
sender, ok := d.senders[ch.Type]
if !ok {
log.Printf("notification: no sender for channel type %q", ch.Type)
return
}
rec := &happydns.NotificationRecord{
UserId: user.Id,
ChannelType: ch.Type,
ChannelId: ch.Id,
CheckerID: payload.CheckerID,
Target: payload.Target,
OldStatus: payload.OldStatus,
NewStatus: payload.NewStatus,
SentAt: time.Now(),
}
if err := sender.Send(ch, payload); err != nil {
log.Printf("notification: failed to send via %s channel %s: %v", ch.Type, ch.Id, err)
rec.Success = false
rec.Error = err.Error()
} else {
rec.Success = true
}
if err := d.recordStore.CreateRecord(rec); err != nil {
log.Printf("notification: failed to log record: %v", err)
}
}
// SendTestNotification sends a test notification through the given channel.
func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error {
sender, ok := d.senders[ch.Type]
if !ok {
return fmt.Errorf("no sender for channel type %q", ch.Type)
}
payload := &notifPkg.NotificationPayload{
User: user,
CheckerID: "test",
DomainName: "example.com",
OldStatus: happydns.StatusOK,
NewStatus: happydns.StatusWarn,
BaseURL: d.baseURL,
}
return sender.Send(ch, payload)
}
func (d *Dispatcher) updateState(state *happydns.NotificationState, newStatus happydns.Status) {
state.LastStatus = newStatus
state.LastNotifiedAt = time.Now()
if err := d.stateStore.PutState(state); err != nil {
log.Printf("notification: failed to update state: %v", err)
}
}

View file

@ -0,0 +1,71 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"time"
"git.happydns.org/happyDomain/model"
)
// NotificationChannelStorage provides persistence for notification channels.
type NotificationChannelStorage interface {
ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error)
GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error)
CreateChannel(ch *happydns.NotificationChannel) error
UpdateChannel(ch *happydns.NotificationChannel) error
DeleteChannel(channelId happydns.Identifier) error
}
// NotificationPreferenceStorage provides persistence for notification preferences.
type NotificationPreferenceStorage interface {
ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error)
GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error)
CreatePreference(pref *happydns.NotificationPreference) error
UpdatePreference(pref *happydns.NotificationPreference) error
DeletePreference(prefId happydns.Identifier) error
}
// NotificationStateStorage provides persistence for notification state tracking.
type NotificationStateStorage interface {
GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error)
PutState(state *happydns.NotificationState) error
DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error
ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error)
}
// NotificationRecordStorage provides persistence for notification audit records.
type NotificationRecordStorage interface {
CreateRecord(rec *happydns.NotificationRecord) error
ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error)
DeleteRecordsOlderThan(before time.Time) error
}
// UserGetter is a narrow interface for loading users in the notification context.
type UserGetter interface {
GetUser(id happydns.Identifier) (*happydns.User, error)
}
// DomainGetter is a narrow interface for loading domains in the notification context.
type DomainGetter interface {
GetDomain(id happydns.Identifier) (*happydns.Domain, error)
}

View file

@ -209,7 +209,7 @@ func (uc *ZoneCorrectionApplierUsecase) Apply(
if len(domain.ZoneHistory) > 1 {
prevZone, prevErr := uc.zoneGetter.Get(domain.ZoneHistory[1])
if prevErr != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1], prevErr)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[1].String(), prevErr)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -62,7 +62,7 @@ func (uc *ZoneImporterUsecase) Import(user *happydns.User, domain *happydns.Doma
if len(domain.ZoneHistory) > 0 {
prevZone, err := uc.zoneGetter.Get(domain.ZoneHistory[0])
if err != nil {
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0], err)
log.Printf("ReassociateMetadata: unable to load previous zone %s: %s (metadata will not be transferred)", domain.ZoneHistory[0].String(), err)
} else {
zoneUC.ReassociateMetadata(prevZone.Services, services, domain.DomainName, defaultTTL)
}

View file

@ -40,8 +40,33 @@ func NewTidyUpUsecase(store storage.Storage) happydns.TidyUpUseCase {
}
}
func (tu *tidyUpUsecase) TidyAll() error {
for _, tidy := range []func() error{
// iterateTidy drives an iterator using NextWithError so Tidy can decide
// whether to delete undecodable records (via DropItem) or just log them.
// handle is only invoked for successfully decoded items.
func iterateTidy[T any](iter happydns.Iterator[T], dropInvalid bool, handle func(*T) error) error {
for iter.NextWithError() {
item := iter.Item()
if item == nil {
key := iter.Key()
log.Printf("KVIterator: error decoding item at key %q: %s", key, iter.Err())
if dropInvalid {
if err := iter.DropItem(); err != nil {
log.Printf("KVIterator: failed to delete invalid item at key %q: %s", key, err)
} else {
log.Printf("KVIterator: dropped invalid item at key %q", key)
}
}
continue
}
if err := handle(item); err != nil {
return err
}
}
return iter.Err()
}
func (tu *tidyUpUsecase) TidyAll(dropInvalid bool) error {
for _, tidy := range []func(bool) error{
tu.TidySessions,
tu.TidyAuthUsers,
tu.TidyUsers,
@ -56,24 +81,22 @@ func (tu *tidyUpUsecase) TidyAll() error {
tu.TidySnapshots,
tu.TidyObservationCache,
} {
if err := tidy(); err != nil {
if err := tidy(dropInvalid); err != nil {
return err
}
}
return nil
}
func (tu *tidyUpUsecase) TidyAuthUsers() error {
func (tu *tidyUpUsecase) TidyAuthUsers(dropInvalid bool) error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
userAuth := iter.Item()
_, err = tu.store.GetUser(userAuth.Id)
return iterateTidy(iter, dropInvalid, func(userAuth *happydns.UserAuth) error {
_, err := tu.store.GetUser(userAuth.Id)
if errors.Is(err, happydns.ErrUserNotFound) && time.Since(userAuth.CreatedAt) > 24*time.Hour {
// Drop providers of unexistant users
log.Printf("Deleting orphan authuser (user %s not found): %v\n", userAuth.Id.String(), userAuth)
@ -81,21 +104,18 @@ func (tu *tidyUpUsecase) TidyAuthUsers() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
func (tu *tidyUpUsecase) TidyCheckEvaluations(dropInvalid bool) error {
iter, err := tu.store.ListAllEvaluations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
eval := iter.Item()
err = iterateTidy(iter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
drop := false
if eval.Target.UserId != "" {
@ -119,36 +139,34 @@ func (tu *tidyUpUsecase) TidyCheckEvaluations() error {
}
if !drop && eval.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
if _, err := tu.store.GetCheckPlan(*eval.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan check evaluation (plan %s not found): %s\n", eval.PlanID.String(), eval.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteEvaluation(eval.Id); err != nil {
if err := tu.store.DeleteEvaluation(eval.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyEvaluationIndexes()
}
func (tu *tidyUpUsecase) TidyCheckPlans() error {
func (tu *tidyUpUsecase) TidyCheckPlans(dropInvalid bool) error {
iter, err := tu.store.ListAllCheckPlans()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
plan := iter.Item()
err = iterateTidy(iter, dropInvalid, func(plan *happydns.CheckPlan) error {
if plan.Target.UserId != "" {
userId, err := happydns.NewIdentifierFromString(plan.Target.UserId)
if err == nil {
@ -157,10 +175,7 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
log.Printf("Deleting orphan check plan (user %s not found): %s\n", plan.Target.UserId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
@ -173,39 +188,31 @@ func (tu *tidyUpUsecase) TidyCheckPlans() error {
log.Printf("Deleting orphan check plan (domain %s not found): %s\n", plan.Target.DomainId, plan.Id.String())
_ = tu.store.DeleteEvaluationsByChecker(plan.CheckerID, plan.Target)
_ = tu.store.DeleteExecutionsByChecker(plan.CheckerID, plan.Target)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
}
if err := iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyCheckPlanIndexes()
}
func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
func (tu *tidyUpUsecase) TidyCheckerConfigurations(dropInvalid bool) error {
iter, err := tu.store.ListAllCheckerConfigurations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
cfg := iter.Item()
return iterateTidy(iter, dropInvalid, func(cfg *happydns.CheckerOptionsPositional) error {
if cfg.UserId != nil {
if _, err = tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
if _, err := tu.store.GetUser(*cfg.UserId); errors.Is(err, happydns.ErrUserNotFound) {
log.Printf("Deleting orphan checker configuration (user %s not found): %s\n", cfg.UserId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
} else if err != nil {
return err
}
@ -215,10 +222,7 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
domain, err := tu.store.GetDomain(*cfg.DomainId)
if errors.Is(err, happydns.ErrDomainNotFound) {
log.Printf("Deleting orphan checker configuration (domain %s not found): %s\n", cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
} else if err != nil {
return err
}
@ -253,28 +257,22 @@ func (tu *tidyUpUsecase) TidyCheckerConfigurations() error {
}
if !found {
log.Printf("Deleting orphan checker configuration (service %s not found in domain %s): %s\n", cfg.ServiceId.String(), cfg.DomainId.String(), cfg.CheckName)
if err = iter.DropItem(); err != nil {
return err
}
continue
return iter.DropItem()
}
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyExecutions() error {
func (tu *tidyUpUsecase) TidyExecutions(dropInvalid bool) error {
iter, err := tu.store.ListAllExecutions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
exec := iter.Item()
err = iterateTidy(iter, dropInvalid, func(exec *happydns.Execution) error {
drop := false
if exec.Target.UserId != "" {
@ -298,58 +296,53 @@ func (tu *tidyUpUsecase) TidyExecutions() error {
}
if !drop && exec.PlanID != nil {
if _, err = tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
if _, err := tu.store.GetCheckPlan(*exec.PlanID); errors.Is(err, happydns.ErrCheckPlanNotFound) {
log.Printf("Deleting orphan execution (plan %s not found): %s\n", exec.PlanID.String(), exec.Id.String())
drop = true
}
}
if drop {
if err = tu.store.DeleteExecution(exec.Id); err != nil {
if err := tu.store.DeleteExecution(exec.Id); err != nil {
return err
}
}
}
if err = iter.Err(); err != nil {
return nil
})
if err != nil {
return err
}
return tu.store.TidyExecutionIndexes()
}
func (tu *tidyUpUsecase) TidyObservationCache() error {
func (tu *tidyUpUsecase) TidyObservationCache(dropInvalid bool) error {
iter, err := tu.store.ListAllCachedObservations()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
entry := iter.Item()
if _, err = tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
return iterateTidy(iter, dropInvalid, func(entry *happydns.ObservationCacheEntry) error {
if _, err := tu.store.GetSnapshot(entry.SnapshotID); errors.Is(err, happydns.ErrSnapshotNotFound) {
log.Printf("Deleting stale observation cache entry (snapshot %s not found)\n", entry.SnapshotID.String())
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyDomains() error {
func (tu *tidyUpUsecase) TidyDomains(dropInvalid bool) error {
iter, err := tu.store.ListAllDomains()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
domain := iter.Item()
if _, err = tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
return iterateTidy(iter, dropInvalid, func(domain *happydns.Domain) error {
if _, err := tu.store.GetUser(domain.Owner); errors.Is(err, happydns.ErrUserNotFound) {
// Drop domain of unexistant users
log.Printf("Deleting orphan domain (user %s not found): %v\n", domain.Owner.String(), domain)
if err = iter.DropItem(); err != nil {
@ -357,51 +350,45 @@ func (tu *tidyUpUsecase) TidyDomains() error {
}
}
if _, err = tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
if _, err := tu.store.GetProvider(domain.ProviderId); errors.Is(err, happydns.ErrProviderNotFound) {
// Drop domain of unexistant provider
log.Printf("Deleting orphan domain (provider %s not found): %v\n", domain.ProviderId.String(), domain)
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyDomainLogs() error {
func (tu *tidyUpUsecase) TidyDomainLogs(dropInvalid bool) error {
iter, err := tu.store.ListAllDomainLogs()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
l := iter.Item()
if _, err = tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
return iterateTidy(iter, dropInvalid, func(l *happydns.DomainLogWithDomainId) error {
if _, err := tu.store.GetDomain(l.DomainId); errors.Is(err, happydns.ErrDomainNotFound) {
// Drop domain of unexistant provider
log.Printf("Deleting orphan domain log (domain %s not found): %v\n", l.DomainId.String(), l)
if err = iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyProviders() error {
func (tu *tidyUpUsecase) TidyProviders(dropInvalid bool) error {
iter, err := tu.store.ListAllProviders()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
prvd := iter.Item()
_, err = tu.store.GetUser(prvd.Owner)
return iterateTidy(iter, dropInvalid, func(prvd *happydns.ProviderMessage) error {
_, err := tu.store.GetUser(prvd.Owner)
if errors.Is(err, happydns.ErrUserNotFound) {
// Drop providers of unexistant users
log.Printf("Deleting orphan provider (user %s not found): %v\n", prvd.Owner.String(), prvd)
@ -409,22 +396,19 @@ func (tu *tidyUpUsecase) TidyProviders() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidySessions() error {
func (tu *tidyUpUsecase) TidySessions(dropInvalid bool) error {
iter, err := tu.store.ListAllSessions()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
session := iter.Item()
_, err = tu.store.GetUser(session.IdUser)
return iterateTidy(iter, dropInvalid, func(session *happydns.Session) error {
_, err := tu.store.GetUser(session.IdUser)
if errors.Is(err, happydns.ErrUserNotFound) {
// Drop session from unexistant users
log.Printf("Deleting orphan session (user %s not found): %v\n", session.IdUser.String(), session)
@ -432,12 +416,11 @@ func (tu *tidyUpUsecase) TidySessions() error {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidySnapshots() error {
func (tu *tidyUpUsecase) TidySnapshots(dropInvalid bool) error {
// Collect all snapshot IDs referenced by evaluations.
evalIter, err := tu.store.ListAllEvaluations()
if err != nil {
@ -446,13 +429,12 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
defer evalIter.Close()
referencedSnapshots := make(map[string]struct{})
for evalIter.Next() {
eval := evalIter.Item()
if err = iterateTidy(evalIter, dropInvalid, func(eval *happydns.CheckEvaluation) error {
if !eval.SnapshotID.IsEmpty() {
referencedSnapshots[eval.SnapshotID.String()] = struct{}{}
}
}
if err = evalIter.Err(); err != nil {
return nil
}); err != nil {
return err
}
@ -463,44 +445,39 @@ func (tu *tidyUpUsecase) TidySnapshots() error {
}
defer iter.Close()
for iter.Next() {
snap := iter.Item()
return iterateTidy(iter, dropInvalid, func(snap *happydns.ObservationSnapshot) error {
if _, ok := referencedSnapshots[snap.Id.String()]; !ok {
log.Printf("Deleting orphan snapshot: %s\n", snap.Id.String())
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyUsers() error {
func (tu *tidyUpUsecase) TidyUsers(dropInvalid bool) error {
iter, err := tu.store.ListAllAuthUsers()
if err != nil {
return err
}
defer iter.Close()
for iter.Next() {
authUser := iter.Item()
return iterateTidy(iter, dropInvalid, func(authUser *happydns.UserAuth) error {
if authUser.EmailVerification == nil && authUser.LastLoggedIn == nil && time.Since(authUser.CreatedAt) > 7*24*time.Hour {
log.Printf("Deleting user with unverified email and no login (created %s): %s\n", authUser.CreatedAt.Format(time.RFC3339), authUser.Email)
if err = tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
if err := tu.store.DeleteUser(authUser.Id); err != nil && !errors.Is(err, happydns.ErrUserNotFound) {
return err
}
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}
func (tu *tidyUpUsecase) TidyZones() error {
func (tu *tidyUpUsecase) TidyZones(dropInvalid bool) error {
iterdn, err := tu.store.ListAllDomains()
if err != nil {
return err
@ -508,15 +485,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
defer iterdn.Close()
var referencedZones []happydns.Identifier
for iterdn.Next() {
domain := iterdn.Item()
for _, zh := range domain.ZoneHistory {
referencedZones = append(referencedZones, zh)
}
}
if err = iterdn.Err(); err != nil {
if err = iterateTidy(iterdn, dropInvalid, func(domain *happydns.Domain) error {
referencedZones = append(referencedZones, domain.ZoneHistory...)
return nil
}); err != nil {
return err
}
@ -526,9 +498,7 @@ func (tu *tidyUpUsecase) TidyZones() error {
}
defer iter.Close()
for iter.Next() {
zone := iter.Item()
return iterateTidy(iter, dropInvalid, func(zone *happydns.ZoneMessage) error {
foundZone := false
for _, zid := range referencedZones {
if zid.Equals(zone.Id) {
@ -540,11 +510,10 @@ func (tu *tidyUpUsecase) TidyZones() error {
if !foundZone {
// Drop orphan zones
log.Printf("Deleting orphan zone: %s\n", zone.Id.String())
if err = iter.DropItem(); err != nil {
if err := iter.DropItem(); err != nil {
return err
}
}
}
return iter.Err()
return nil
})
}

View file

@ -80,7 +80,7 @@ func TestTidyObservationCache_RemovesStaleEntries(t *testing.T) {
// Run tidy.
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
if err := tu.TidyObservationCache(true); err != nil {
t.Fatalf("TidyObservationCache() error: %v", err)
}
@ -102,7 +102,7 @@ func TestTidyObservationCache_EmptyCache(t *testing.T) {
}
tu := usecase.NewTidyUpUsecase(store)
if err := tu.TidyObservationCache(); err != nil {
if err := tu.TidyObservationCache(true); err != nil {
t.Fatalf("TidyObservationCache() on empty cache error: %v", err)
}
}

View file

@ -22,13 +22,17 @@
package happydns
type Backup struct {
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
Version int
Domains []*Domain
DomainsLogs map[string][]*DomainLog
Errors []string
Providers []*ProviderMessage
Sessions []*Session
Users []*User
UsersAuth UserAuths
Zones []*ZoneMessage
CheckerConfigurations []*CheckerOptionsPositional
CheckPlans []*CheckPlan
CheckEvaluations []*CheckEvaluation
Executions []*Execution
}

View file

@ -124,6 +124,10 @@ type Options struct {
// false, manual triggers bypass the quota entirely.
CheckerCountManualTriggers bool
// NotificationRetentionDays is how many days of notification history
// records are kept. Defaults to 90.
NotificationRetentionDays int
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string

View file

@ -42,6 +42,10 @@ var (
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrNotificationChannelNotFound = errors.New("notification channel not found")
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
ErrNotificationStateNotFound = errors.New("notification state not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

178
model/notification.go Normal file
View file

@ -0,0 +1,178 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
import "time"
// NotificationChannelType identifies the transport used to deliver a notification.
type NotificationChannelType string
const (
NotificationChannelEmail NotificationChannelType = "email"
NotificationChannelWebhook NotificationChannelType = "webhook"
NotificationChannelUnifiedPush NotificationChannelType = "unifiedpush"
)
// NotificationChannelConfig holds channel-specific configuration.
type NotificationChannelConfig struct {
// EmailAddress overrides the user's account email. Empty means use account email.
EmailAddress string `json:"emailAddress,omitempty"`
// WebhookURL is the HTTP endpoint to POST to.
WebhookURL string `json:"webhookUrl,omitempty"`
// WebhookHeaders are extra headers sent with webhook requests.
WebhookHeaders map[string]string `json:"webhookHeaders,omitempty"`
// WebhookSecret is used to compute an HMAC-SHA256 signature header.
WebhookSecret string `json:"webhookSecret,omitempty"`
// UnifiedPushEndpoint is the push server endpoint URL.
UnifiedPushEndpoint string `json:"unifiedPushEndpoint,omitempty"`
}
// NotificationChannel represents a single configured notification destination.
type NotificationChannel struct {
// Id is the channel's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the owner of the channel.
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
// Type is the transport type (email, webhook, unifiedpush).
Type NotificationChannelType `json:"type" binding:"required"`
// Name is a human-readable label for the channel.
Name string `json:"name"`
// Enabled controls whether notifications are sent through this channel.
Enabled bool `json:"enabled"`
// Config holds channel-specific settings.
Config NotificationChannelConfig `json:"config"`
}
// NotificationPreference controls what notifications a user receives for a given scope.
// Scope resolution: ServiceId set > DomainId set > global (both nil).
type NotificationPreference struct {
// Id is the preference's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the owner of the preference.
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
// DomainId, if set, scopes this preference to a specific domain.
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
// ServiceId, if set, scopes this preference to a specific service.
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
// ChannelIds restricts which channels to use. Empty means all enabled channels.
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
// MinStatus is the minimum severity that triggers a notification.
MinStatus Status `json:"minStatus"`
// NotifyRecovery controls whether recovery (back to OK) notifications are sent.
NotifyRecovery bool `json:"notifyRecovery"`
// QuietStart is the start hour (0-23, UTC) of a quiet window.
QuietStart *int `json:"quietStart,omitempty"`
// QuietEnd is the end hour (0-23, UTC) of a quiet window.
QuietEnd *int `json:"quietEnd,omitempty"`
// Enabled is the master switch for this preference scope.
Enabled bool `json:"enabled"`
}
// NotificationState tracks the last notified status for a (checker, target, user) tuple.
// Used for deduplication: only state transitions trigger notifications.
type NotificationState struct {
// CheckerID identifies the checker.
CheckerID string `json:"checkerId"`
// Target is the checked scope.
Target CheckTarget `json:"target"`
// UserId is the user who owns the target.
UserId Identifier `json:"userId" swaggertype:"string"`
// LastStatus is the status from the last notification.
LastStatus Status `json:"lastStatus"`
// LastNotifiedAt is when the last notification was sent.
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
// Acknowledged indicates the user has acknowledged the current issue.
Acknowledged bool `json:"acknowledged"`
// AcknowledgedAt is when the issue was acknowledged.
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
// AcknowledgedBy describes who acknowledged (user email or "api").
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
// Annotation is a user-provided note on the acknowledgement.
Annotation string `json:"annotation,omitempty"`
}
// NotificationRecord logs a sent notification for audit purposes.
type NotificationRecord struct {
// Id is the record's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the recipient user.
UserId Identifier `json:"userId" swaggertype:"string"`
// ChannelType is the transport used.
ChannelType NotificationChannelType `json:"channelType"`
// ChannelId is the channel through which the notification was sent.
ChannelId Identifier `json:"channelId" swaggertype:"string"`
// CheckerID is the checker that triggered the notification.
CheckerID string `json:"checkerId"`
// Target is the checked scope.
Target CheckTarget `json:"target"`
// OldStatus is the previous status before the transition.
OldStatus Status `json:"oldStatus"`
// NewStatus is the new status that triggered the notification.
NewStatus Status `json:"newStatus"`
// SentAt is when the notification was dispatched.
SentAt time.Time `json:"sentAt" format:"date-time"`
// Success indicates whether the send succeeded.
Success bool `json:"success"`
// Error holds the error message if the send failed.
Error string `json:"error,omitempty"`
}
// AcknowledgeRequest is the JSON body for acknowledging a checker issue.
type AcknowledgeRequest struct {
Annotation string `json:"annotation,omitempty"`
}

View file

@ -24,18 +24,21 @@ package happydns
import ()
type TidyUpUseCase interface {
TidyAll() error
TidyAuthUsers() error
TidyCheckEvaluations() error
TidyCheckPlans() error
TidyCheckerConfigurations() error
TidyExecutions() error
TidyObservationCache() error
TidySnapshots() error
TidyDomains() error
TidyDomainLogs() error
TidyProviders() error
TidySessions() error
TidyUsers() error
TidyZones() error
// TidyAll runs every tidy pass. When dropInvalid is true, iterators
// that encounter undecodable records (e.g. legacy schema drift) will
// delete those records; otherwise they are only logged.
TidyAll(dropInvalid bool) error
TidyAuthUsers(dropInvalid bool) error
TidyCheckEvaluations(dropInvalid bool) error
TidyCheckPlans(dropInvalid bool) error
TidyCheckerConfigurations(dropInvalid bool) error
TidyExecutions(dropInvalid bool) error
TidyObservationCache(dropInvalid bool) error
TidySnapshots(dropInvalid bool) error
TidyDomains(dropInvalid bool) error
TidyDomainLogs(dropInvalid bool) error
TidyProviders(dropInvalid bool) error
TidySessions(dropInvalid bool) error
TidyUsers(dropInvalid bool) error
TidyZones(dropInvalid bool) error
}

View file

@ -1,48 +1,16 @@
{
"name": "happyDomain",
"name": "happydomain-monorepo",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "happyDomain",
"name": "happydomain-monorepo",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
"workspaces": [
"web",
"web-admin"
]
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
@ -227,6 +195,14 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@happydomain/web": {
"resolved": "web",
"link": true
},
"node_modules/@happydomain/web-admin": {
"resolved": "web-admin",
"link": true
},
"node_modules/@hey-api/codegen-core": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz",
@ -332,29 +308,43 @@
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@ -441,9 +431,9 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@ -1269,17 +1259,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
"integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/type-utils": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/type-utils": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@ -1292,7 +1282,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.58.1",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@ -1308,16 +1298,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz",
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3"
},
"engines": {
@ -1333,14 +1323,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz",
"integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
"integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.58.1",
"@typescript-eslint/types": "^8.58.1",
"@typescript-eslint/tsconfig-utils": "^8.58.2",
"@typescript-eslint/types": "^8.58.2",
"debug": "^4.4.3"
},
"engines": {
@ -1355,14 +1345,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz",
"integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
"integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1"
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1373,9 +1363,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz",
"integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
"integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
"dev": true,
"license": "MIT",
"engines": {
@ -1390,15 +1380,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz",
"integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
"integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@ -1415,9 +1405,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
"integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -1429,16 +1419,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz",
"integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
"integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.58.1",
"@typescript-eslint/tsconfig-utils": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/visitor-keys": "8.58.1",
"@typescript-eslint/project-service": "8.58.2",
"@typescript-eslint/tsconfig-utils": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@ -1457,16 +1447,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz",
"integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
"integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1"
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1481,13 +1471,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz",
"integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
"integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.58.1",
"@typescript-eslint/types": "8.58.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@ -2082,18 +2072,18 @@
}
},
"node_modules/eslint": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz",
"integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.4",
"@eslint/config-helpers": "^0.5.4",
"@eslint/core": "^1.2.0",
"@eslint/plugin-kit": "^0.7.0",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.5.5",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@ -3302,9 +3292,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@ -3461,9 +3451,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@ -3755,16 +3745,16 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svelte": {
"version": "5.55.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
"version": "5.55.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@ -4093,16 +4083,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz",
"integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==",
"version": "8.58.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
"integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.58.1",
"@typescript-eslint/parser": "8.58.1",
"@typescript-eslint/typescript-estree": "8.58.1",
"@typescript-eslint/utils": "8.58.1"
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"@typescript-eslint/typescript-estree": "8.58.2",
"@typescript-eslint/utils": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4420,6 +4410,86 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
},
"web": {
"name": "@happydomain/web",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
},
"web-admin": {
"name": "@happydomain/web-admin",
"version": "0.0.1",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
}
}
}
}

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "happydomain-monorepo",
"version": "0.0.1",
"private": true,
"workspaces": [
"web",
"web-admin"
],
"resolutions": {
"vite": "^8.0.0"
}
}

View file

@ -56,6 +56,13 @@ func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.
return nil, err
}
// Some registries (e.g. Verisign for .com) return a "No match" response
// that the parser accepts without error but produces an empty Domain
// field. Treat this as a non-existent domain.
if result.Domain == nil || result.Domain.Domain == "" {
return nil, happydns.ErrDomainDoesNotExist
}
return mapWhoisResult(&result), nil
}

View file

@ -55,6 +55,10 @@ var entityMap = map[string]string{
"SessionStorage": "session",
"UserStorage": "user",
"ZoneStorage": "zone",
"NotificationChannelStorage": "notification_channel",
"NotificationPreferenceStorage": "notification_preference",
"NotificationRecordStorage": "notification_record",
"NotificationStateStorage": "notification_state",
}
// operationOverrides maps method names that don't follow the prefix convention.
@ -311,6 +315,7 @@ func inferOperation(name string) string {
{"Count", "count"},
{"Create", "create"},
{"Update", "update"},
{"Restore", "restore"},
{"Delete", "delete"},
{"Clear", "delete"},
{"Set", "set"},

View file

@ -1 +0,0 @@
../web/.npmrc

View file

@ -1 +0,0 @@
../web/.prettierignore

13
web-admin/.prettierignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1 +0,0 @@
../web/.prettierrc

View file

@ -1 +0,0 @@
../web/eslint.config.js

View file

@ -1 +0,0 @@
../web/node_modules/

View file

@ -1 +0,0 @@
../web/package-lock.json

View file

@ -1 +0,0 @@
../web/package.json

56
web-admin/package.json Normal file
View file

@ -0,0 +1,56 @@
{
"name": "@happydomain/web-admin",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write .",
"generate:api": "openapi-ts"
},
"devDependencies": {
"@eslint/compat": "^2.0.0",
"@eslint/js": "^10.0.0",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/node": "^25.0.10",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^17.0.0",
"prettier": "^3.4.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-preprocess": "^6.0.0",
"tslib": "^2.8.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.20.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"resolutions": {
"vite": "^8.0.0"
},
"type": "module",
"dependencies": {
"@hey-api/openapi-ts": "^0.95.0",
"@sveltestrap/sveltestrap": "^7.0.0",
"bootstrap": "^5.3.0",
"bootstrap-icons": "^1.13.0",
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"html-escaper": "^3.0.0",
"sass": "^1.97.0",
"sass-loader": "^16.0.0",
"sveltekit-i18n": "^2.4.0"
}
}

View file

@ -22,86 +22,282 @@
-->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import {
Alert,
Badge,
Button,
Card,
CardFooter,
CardHeader,
Col,
Collapse,
Container,
Icon,
ListGroup,
ListGroupItem,
Row,
} from "@sveltestrap/sveltestrap";
import { getDomains, getProviders, getUsers } from '$lib/api-admin';
import { fetchMetrics, firstLabel, singleValue, sumValues, type Metrics } from "$lib/metrics";
import { formatBytes, formatDuration } from "$lib/utils";
// formatDuration in $lib/utils takes nanoseconds and may emit decimals;
// metrics expose seconds and we want whole units, so floor to a unit
// boundary before delegating.
function fmtSeconds(s: number | undefined): string {
if (s === undefined || !Number.isFinite(s)) return formatDuration(undefined);
const sec = Math.floor(s);
let unitNs: number;
if (sec < 60) unitNs = 1e9;
else if (sec < 3600) unitNs = 60 * 1e9;
else if (sec < 86400) unitNs = 3600 * 1e9;
else unitNs = 86400 * 1e9;
return formatDuration(Math.floor((sec * 1e9) / unitNs) * unitNs);
}
import DatabaseBackupCard from "./DatabaseBackupCard.svelte";
import TidyCard from "./TidyCard.svelte";
let totalUsers: number | undefined = $state();
getUsers().then((res) => { totalUsers = res.data?.length || 0; });
let metrics: Metrics | undefined = $state();
let metricsError: string | undefined = $state();
let lastUpdated: Date | undefined = $state();
let isRefreshing = $state(false);
let showMore = $state(false);
let now = $state(Date.now() / 1000);
let refreshTimer: ReturnType<typeof setInterval> | undefined;
let tickTimer: ReturnType<typeof setInterval> | undefined;
let totalDomains: number | undefined = $state();
getDomains().then((res) => { totalDomains = res.data?.length || 0; });
async function refresh() {
isRefreshing = true;
try {
metrics = await fetchMetrics();
metricsError = undefined;
lastUpdated = new Date();
} catch (err) {
metricsError = err instanceof Error ? err.message : String(err);
} finally {
isRefreshing = false;
}
}
let totalProviders: number | undefined = $state();
getProviders().then((res) => { totalProviders = res.data?.length || 0; });
onMount(() => {
refresh();
refreshTimer = setInterval(refresh, 15000);
tickTimer = setInterval(() => (now = Date.now() / 1000), 1000);
});
onDestroy(() => {
if (refreshTimer) clearInterval(refreshTimer);
if (tickTimer) clearInterval(tickTimer);
});
let totalUsers = $derived(singleValue(metrics ?? {}, "happydomain_registered_users"));
let totalDomains = $derived(singleValue(metrics ?? {}, "happydomain_domains"));
let totalProviders = $derived(singleValue(metrics ?? {}, "happydomain_providers"));
let totalZones = $derived(singleValue(metrics ?? {}, "happydomain_zones"));
let schedulerQueue = $derived(singleValue(metrics ?? {}, "happydomain_scheduler_queue_depth"));
let schedulerWorkers = $derived(
singleValue(metrics ?? {}, "happydomain_scheduler_active_workers"),
);
let httpInFlight = $derived(singleValue(metrics ?? {}, "happydomain_http_requests_in_flight"));
let buildVersion = $derived(firstLabel(metrics ?? {}, "happydomain_build_info", "version"));
let httpRequestsTotal = $derived(sumValues(metrics ?? {}, "happydomain_http_requests_total"));
let checksTotal = $derived(sumValues(metrics ?? {}, "happydomain_scheduler_checks_total"));
let providerCallsTotal = $derived(
sumValues(metrics ?? {}, "happydomain_provider_api_calls_total"),
);
let storageOpsTotal = $derived(
sumValues(metrics ?? {}, "happydomain_storage_operations_total"),
);
let storageStatsErrors = $derived(
sumValues(metrics ?? {}, "happydomain_storage_stats_errors_total"),
);
let goRoutines = $derived(singleValue(metrics ?? {}, "go_goroutines"));
let goThreads = $derived(singleValue(metrics ?? {}, "go_threads"));
let goMemAlloc = $derived(singleValue(metrics ?? {}, "go_memstats_alloc_bytes"));
let processRSS = $derived(singleValue(metrics ?? {}, "process_resident_memory_bytes"));
let processCPU = $derived(singleValue(metrics ?? {}, "process_cpu_seconds_total"));
let processOpenFDs = $derived(singleValue(metrics ?? {}, "process_open_fds"));
let processStart = $derived(singleValue(metrics ?? {}, "process_start_time_seconds"));
let uptime = $derived(processStart === undefined ? undefined : now - processStart);
function fmt(v: number | undefined): string {
if (v === undefined || !Number.isFinite(v)) return "—";
return Math.round(v).toLocaleString();
}
let checksFailed = $derived.by(() => {
const samples = metrics?.["happydomain_scheduler_checks_total"];
if (!samples) return undefined;
return samples
.filter((s) => {
const st = s.labels["status"];
return st && st !== "ok" && st !== "success";
})
.reduce((acc, s) => acc + s.value, 0);
});
</script>
{#snippet tile(label: string, value: string, sub: string | null, icon: string, color: string)}
<Col>
<Card body class="h-100 border-0 shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="text-muted text-uppercase small mb-0">{label}</h6>
<i class="bi bi-{icon} text-{color}" style="font-size: 1.25rem;"></i>
</div>
<div class="fs-2 fw-semibold font-monospace">{value}</div>
{#if sub}<div class="small text-muted">{sub}</div>{/if}
</Card>
</Col>
{/snippet}
{#snippet row(label: string, value: string, badge: string | null)}
<ListGroupItem class="d-flex justify-content-between align-items-center bg-transparent">
<span class="text-muted small">{label}</span>
<span class="font-monospace">
{value}
{#if badge}<Badge color="warning" class="text-dark ms-2">{badge}</Badge>{/if}
</span>
</ListGroupItem>
{/snippet}
<Container class="flex-fill my-5">
<div class="row mb-4">
<div class="col">
<h1 class="display-4">
<i class="bi bi-speedometer2"></i>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="display-5 mb-1">
<Icon name="speedometer2" class="text-primary"></Icon>
Admin Dashboard
</h1>
<p class="text-muted">System overview and management</p>
<p class="text-muted mb-0">
Live telemetry from <code>/metrics</code>, refreshed every 15s.
</p>
</div>
<Button type="button" color="secondary" outline disabled={isRefreshing} on:click={refresh}>
<Icon name="arrow-repeat" class="me-1 {isRefreshing ? 'spin' : ''}"></Icon>
Refresh
</Button>
</div>
<div class="row row-cols-sm-2 row-cols-lg-3 g-4">
<div class="col">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Total Users</h6>
<h2 class="mb-0">{totalUsers}</h2>
</div>
<div class="text-primary">
<i class="bi bi-people-fill" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Total Domains</h6>
<h2 class="mb-0">{totalDomains}</h2>
</div>
<div class="text-primary">
<i class="bi bi-globe" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">Providers</h6>
<h2 class="mb-0">{totalProviders}</h2>
</div>
<div class="text-primary">
<i class="bi bi-hdd-network-fill" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-4">
{#if buildVersion}
<Badge class="bg-secondary-subtle text-secondary-emphasis border">
<Icon name="tag" class="me-1"></Icon>v{buildVersion}
</Badge>
{/if}
<Badge class="bg-secondary-subtle text-secondary-emphasis border tnum">
<i class="bi bi-clock me-1"></i>uptime {fmtSeconds(uptime)}
</Badge>
<Badge class="bg-success-subtle text-success-emphasis border tnum">
<Icon name="broadcast" class="me-1"></Icon>
{lastUpdated ? `updated ${lastUpdated.toLocaleTimeString()}` : "connecting…"}
</Badge>
</div>
{#if metricsError}
<Alert color="warning" class="d-flex align-items-center">
<Icon name="exclamation-triangle" class="me-2"></Icon>
<div>Failed to load metrics: {metricsError}</div>
</Alert>
{/if}
<h2 class="h5 text-muted text-uppercase small fw-bold mt-4 mb-3">Inventory</h2>
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
{@render tile("Users", fmt(totalUsers), "registered", "people-fill", "primary")}
{@render tile("Domains", fmt(totalDomains), "managed", "globe2", "primary")}
{@render tile(
"Providers",
fmt(totalProviders),
"DNS backends",
"hdd-network-fill",
"primary",
)}
{@render tile("Zone snapshots", fmt(totalZones), "stored", "clock-history", "primary")}
</div>
<h2 class="h5 text-muted text-uppercase small fw-bold mt-4 mb-3">Runtime</h2>
<div class="row row-cols-2 row-cols-lg-4 g-3 mb-4">
{@render tile("Checker queue", fmt(schedulerQueue), "queued", "list-task", "info")}
{@render tile("Active workers", fmt(schedulerWorkers), "running", "cpu", "info")}
{@render tile("HTTP in flight", fmt(httpInFlight), "serving", "arrow-left-right", "info")}
{@render tile("Memory RSS", formatBytes(processRSS), "resident", "memory", "info")}
</div>
<div class="text-center mb-3">
<Button
color="link"
class="text-decoration-none"
onclick={() => (showMore = !showMore)}
aria-expanded={showMore}
>
{showMore ? "Hide" : "Show"} detailed metrics
<i class="bi bi-chevron-{showMore ? 'up' : 'down'} ms-1"></i>
</Button>
</div>
<Collapse isOpen={showMore}>
<Card class="border-0 shadow-sm mb-4">
<CardHeader class="bg-transparent">
<h3 class="h6 mb-0">Detailed metrics</h3>
</CardHeader>
<Row class="g-0">
<Col md={6}>
<div class="px-3 pt-2 small text-uppercase text-muted fw-bold">
Traffic &amp; work
</div>
<ListGroup flush>
{@render row("HTTP requests served", fmt(httpRequestsTotal), null)}
{@render row(
"Checks executed",
fmt(checksTotal),
checksFailed !== undefined && checksFailed > 0
? `${fmt(checksFailed)} failed`
: null,
)}
{@render row("Provider API calls", fmt(providerCallsTotal), null)}
{@render row("Storage operations", fmt(storageOpsTotal), null)}
{@render row(
"Storage stats errors",
fmt(storageStatsErrors),
storageStatsErrors && storageStatsErrors > 0 ? "warn" : null,
)}
</ListGroup>
</Col>
<Col md={6}>
<div class="px-3 pt-2 small text-uppercase text-muted fw-bold">
Runtime &amp; process
</div>
<ListGroup flush>
{@render row("CPU time", fmtSeconds(processCPU), null)}
{@render row("Heap allocated", formatBytes(goMemAlloc), null)}
{@render row("Goroutines", fmt(goRoutines), null)}
{@render row("OS threads", fmt(goThreads), null)}
{@render row("Open file descriptors", fmt(processOpenFDs), null)}
</ListGroup>
</Col>
</Row>
<CardFooter class="bg-transparent text-muted small">
Source: <a href="/metrics" target="_blank" rel="noopener">/metrics</a> (Prometheus).
</CardFooter>
</Card>
</Collapse>
<TidyCard class="my-4" />
<DatabaseBackupCard class="my-4" />
</Container>
<style>
.tnum {
font-variant-numeric: tabular-nums;
}
.spin {
display: inline-block;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,5 +1,5 @@
{
"name": "happyDomain",
"name": "@happydomain/web",
"version": "0.0.1",
"private": true,
"scripts": {

View file

@ -25,6 +25,7 @@
import { Alert, Button, Card, Col, Icon, Row } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { base } from "$lib/stores/config";
import { checkers } from "$lib/stores/checkers";
import { toasts } from "$lib/stores/toasts";
import type {
@ -66,6 +67,11 @@
let checkerDef = $derived($checkers?.[checkerId]);
let intervalSpec = $derived(checkerDef?.interval);
let metricsApiUrl = $derived(
scope.zoneId && scope.subdomain !== undefined && scope.serviceId
? `${base}/api/domains/${scope.domainId}/zone/${scope.zoneId}/${scope.subdomain}/services/${scope.serviceId}/checkers/${encodeURIComponent(checkerId)}/metrics`
: `${base}/api/domains/${scope.domainId}/checkers/${encodeURIComponent(checkerId)}/metrics`
);
let plan = $state<HappydnsCheckPlanWritable>({
enabled: {},
@ -92,6 +98,22 @@
});
});
async function copyMetricsUrl() {
try {
await navigator.clipboard.writeText(metricsApiUrl);
toasts.addToast({
message: $t("checkers.list.prometheus-metrics-copied"),
type: "success",
timeout: 3000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.list.prometheus-metrics-copy-failed", { error: String(error) }),
timeout: 5000,
});
}
}
async function saveOptions() {
savingOptions = true;
try {
@ -128,6 +150,17 @@
{$t("checkers.list.view-results")}
</Button>
{/if}
{#if checkerDef?.has_metrics}
<Button
color="secondary"
outline
onclick={copyMetricsUrl}
title={metricsApiUrl}
>
<Icon name="graph-up-arrow"></Icon>
{$t("checkers.list.prometheus-metrics")}
</Button>
{/if}
</PageTitle>
{#await checkStatusPromise}

View file

@ -22,9 +22,11 @@
-->
<script lang="ts">
import { Alert, Badge, Card, Icon, Table } from "@sveltestrap/sveltestrap";
import { Alert, Badge, Button, Card, Icon, Table } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { base } from "$lib/stores/config";
import { toasts } from "$lib/stores/toasts";
import type { CheckerScope } from "$lib/api/checkers";
import { listScopedCheckers } from "$lib/api/checkers";
import { checkers } from "$lib/stores/checkers";
@ -45,6 +47,28 @@
let checkersPromise = $derived(listScopedCheckers(scope));
let metricsApiUrl = $derived(
scope.zoneId && scope.subdomain !== undefined && scope.serviceId
? `${base}/api/domains/${scope.domainId}/zone/${scope.zoneId}/${scope.subdomain}/services/${scope.serviceId}/checkers/metrics`
: `${base}/api/domains/${scope.domainId}/checkers/metrics`
);
async function copyMetricsUrl() {
try {
await navigator.clipboard.writeText(metricsApiUrl);
toasts.addToast({
message: $t("checkers.list.prometheus-metrics-copied"),
type: "success",
timeout: 3000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checkers.list.prometheus-metrics-copy-failed", { error: String(error) }),
timeout: 5000,
});
}
}
function getConfiguredCheckerIds(statuses: HappydnsCheckerStatus[]): Set<string> {
return new Set(statuses.map((s) => s.id).filter((id): id is string => !!id));
}
@ -72,7 +96,17 @@
</svelte:head>
<div class="flex-fill mt-1 mb-5">
<PageTitle {title} domain={domainName}></PageTitle>
<PageTitle {title} domain={domainName}>
<Button
color="secondary"
outline
onclick={copyMetricsUrl}
title={metricsApiUrl}
>
<Icon name="graph-up-arrow"></Icon>
{$t("checkers.list.prometheus-metrics")}
</Button>
</PageTitle>
{#await checkersPromise}
<Card body>

View file

@ -620,6 +620,9 @@
"loading": "Loading checkers...",
"no-checks": "No checks available for this domain.",
"view-results": "View Results",
"prometheus-metrics": "Prometheus Metrics",
"prometheus-metrics-copied": "Prometheus metrics URL copied to clipboard",
"prometheus-metrics-copy-failed": "Failed to copy Prometheus metrics URL: {{error}}",
"configure": "Configure",
"error-loading": "Error loading checkers: {{error}}",
"table": {

94
web/src/lib/metrics.ts Normal file
View file

@ -0,0 +1,94 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
export type MetricSample = {
name: string;
labels: Record<string, string>;
value: number;
};
export type Metrics = Record<string, MetricSample[]>;
// Minimal Prometheus text format parser. Handles standard lines of the form
// `metric_name{label="value",...} number` and ignores HELP/TYPE/comment lines.
export function parsePrometheusText(text: string): Metrics {
const out: Metrics = {};
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
// Split name(+labels) from value (last whitespace-separated token before optional timestamp).
const braceEnd = line.indexOf("}");
let head: string;
let rest: string;
if (braceEnd >= 0) {
head = line.slice(0, braceEnd + 1);
rest = line.slice(braceEnd + 1).trim();
} else {
const sp = line.indexOf(" ");
if (sp < 0) continue;
head = line.slice(0, sp);
rest = line.slice(sp + 1).trim();
}
const valueToken = rest.split(/\s+/)[0];
const value = Number(valueToken);
if (!Number.isFinite(value)) continue;
let name = head;
const labels: Record<string, string> = {};
const lb = head.indexOf("{");
if (lb >= 0) {
name = head.slice(0, lb);
const labelStr = head.slice(lb + 1, head.lastIndexOf("}"));
// Naive label parser; sufficient for values without escaped quotes/commas
const re = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(labelStr)) !== null) {
labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
}
(out[name] ||= []).push({ name, labels, value });
}
return out;
}
export async function fetchMetrics(): Promise<Metrics> {
const res = await fetch("/metrics", { headers: { Accept: "text/plain" } });
if (!res.ok) {
throw new Error(`Failed to fetch /metrics: ${res.status} ${res.statusText}`);
}
return parsePrometheusText(await res.text());
}
// Returns the single value of a metric, or undefined if absent.
export function singleValue(metrics: Metrics, name: string): number | undefined {
const samples = metrics[name];
if (!samples || samples.length === 0) return undefined;
return samples[0].value;
}
// Sums all samples of a metric (useful for *_total counters with labels).
export function sumValues(metrics: Metrics, name: string): number | undefined {
const samples = metrics[name];
if (!samples || samples.length === 0) return undefined;
return samples.reduce((acc, s) => acc + s.value, 0);
}
// Returns the first label value found for a metric (e.g. build version).
export function firstLabel(metrics: Metrics, name: string, label: string): string | undefined {
const samples = metrics[name];
if (!samples || samples.length === 0) return undefined;
return samples[0].labels[label];
}

View file

@ -0,0 +1,16 @@
/**
* Format a byte count into a human-readable IEC string (KiB, MiB, ...).
* @param n Number of bytes
* @returns Human-readable string such as "1.4 MiB", or "—" if undefined
*/
export function formatBytes(n: number | undefined): string {
if (n === undefined || !Number.isFinite(n)) return "—";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}

View file

@ -3,4 +3,5 @@
*/
export { toDatetimeLocal, fromDatetimeLocal, formatDuration } from './datetime';
export { formatBytes } from './format';
export { getStatusColor, getStatusIcon, getStatusI18nKey, getExecutionStatusColor, getExecutionStatusI18nKey, formatCheckDate, withInheritedPlaceholders, splitPositionalOptions, downloadBlob, collectAllOptionDocs, availabilityBadges, getOrphanedOptionKeys, filterValidOptions } from './checkers';

View file

@ -132,7 +132,7 @@
</div>
{:then sessions}
<ListGroup>
{#each sessions as session (session.id)}
{#each [...sessions].sort((a, b) => new Date(b.upd || b.time).getTime() - new Date(a.upd || a.time).getTime()) as session (session.id)}
<ListGroupItem class="d-flex align-items-center justify-content-between">
<div class="flex-fill" style="max-width:90%">
<div class="text-truncate">