Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
CWD=$$(pwd)
.PHONY: help
.PHONY: help announcements

help: ## Displays the help for each command.
@grep -E '^[a-zA-Z_-]+:.*## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Expand Down Expand Up @@ -122,6 +122,14 @@ restart_dev: ## Restarts development services (down + up with proper wait).
-f compose.sdns.yml \
up -d

announcements: ## Serves the Announcements dev fixture on the dns network as http://announcements-dev/ (set ANNOUNCEMENTS_URL in api/.env + recreate dnsapi; see README-dev.md).
@NET=$$(docker inspect -f '{{range $$k,$$v := .NetworkSettings.Networks}}{{$$k}}{{end}}' dnsapi 2>/dev/null); \
if [ -z "$$NET" ]; then echo "dnsapi container not found — start the stack first (make up / make up_dev)."; exit 1; fi; \
docker rm -f announcements-dev >/dev/null 2>&1 || true; \
echo "Serving bootstrap/announcements/ on network $$NET as http://announcements-dev/announcements.md (Ctrl-C to stop)"; \
echo "api/.env must have ANNOUNCEMENTS_URL=http://announcements-dev/announcements.md and ANNOUNCEMENTS_RELOAD=10s; recreate dnsapi once after setting them (env is read at container start)."; \
docker run --rm --name announcements-dev --network "$$NET" -v "$$(pwd)/bootstrap/announcements:/usr/share/nginx/html:ro" nginx:alpine

IMAGE?=dnsapi
build_api_image: ## Builds the DNS REST API image.
docker build -t ${IMAGE} -f api/Dockerfile .
Expand Down
57 changes: 57 additions & 0 deletions README-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,63 @@ Helpful `/etc/hosts` entries (in addition to dnsmasq):

3. If you need DoT/DoQ validation, ensure your DNS client trusts the same certificate.

## Testing the Announcements feature locally

The API serves announcements by fetching a single Markdown file over HTTP from
`ANNOUNCEMENTS_URL` (in production this is the raw URL of the `announcements`
content branch). To exercise the feature locally without that branch, serve the
bundled dev fixture with any static file server.

A ready-made fixture lives at `bootstrap/announcements/announcements.md`. It
covers every category (`news`, `feature`, `maintenance`, `incident`, `security`,
`policy`) and severity (`info`, `warning`, `critical`), plus one expired and one
future entry to confirm the API hides them.

1. Put the dev URL in `api/.env` (with a short reload for fast iteration):

```
ANNOUNCEMENTS_URL=http://announcements-dev/announcements.md
ANNOUNCEMENTS_RELOAD=10s
```

> [!IMPORTANT]
> The API reads `ANNOUNCEMENTS_URL` from `env_file` **only at container
> creation** (there is no in-process `.env` loader). After editing `api/.env`
> you must **recreate** the `dnsapi` container — `make down && make up`
> (or `make restart_dev`) — not just restart the process. `docker exec dnsapi
> printenv ANNOUNCEMENTS_URL` shows the value the running API actually sees.

2. With the stack up, serve the fixture (in its own terminal):

```bash
make announcements
```

This runs a throwaway nginx named `announcements-dev` on the shared
`dns_dnsnetwork`, so the API reaches it by container DNS name at
`http://announcements-dev/announcements.md`. Because it lives on the network
(not inside the API's namespace) it survives `dnsapi` restarts. Editing the
`.md` afterwards is picked up within the reload interval — no restart needed.
If you run `make down`, the network is torn down too, so re-run
`make announcements` after the next `make up`.

3. Open the web UI and visit `/announcements` (reachable logged in *or* logged
out). Verify each category renders with its badge and severity-coloured
accent, and that the two `(should be hidden)` entries do **not** appear.

4. The nav "Announcements" entry shows an unread dot: **red** when an unread
announcement is `critical` (the fixture's `dev-incident`), brand-coloured
otherwise. Opening the page marks everything seen and clears the dot; the
last-seen timestamp is persisted under the `moddns-storage` key in
`localStorage`. Clear that key (DevTools → Application → Local Storage) or
use a private window to re-test the dot.

> [!NOTE]
> If you run the API **on the host** instead of in the `dnsapi` container, skip
> `make announcements` and serve the fixture directly with
> `cd bootstrap/announcements && python3 -m http.server 8099`, using
> `ANNOUNCEMENTS_URL=http://localhost:8099/announcements.md`.

## Troubleshooting

- **TLS errors**: confirm the CA is trusted and the certificate's SAN includes the host you're testing (`*.ivpndns.com`).
Expand Down
1 change: 1 addition & 0 deletions api/api/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func (suite *AccountsAPITestSuite) createTestServer() *APIServer {
mockMailer,
mockShortener,
nil,
nil,
)
suite.Require().NoError(err, "Failed to create test server")

Expand Down
21 changes: 21 additions & 0 deletions api/api/announcements.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package api

import (
"time"

"github.com/gofiber/fiber/v2"
"github.com/ivpn/dns/api/internal/announcements"
)

// @Summary Get announcements
// @Description Get the list of currently published announcements. Public endpoint (no authentication).
// @Tags Announcements
// @Produce json
// @Success 200 {array} announcements.Announcement
// @Router /api/v1/announcements [get]
func (s *APIServer) getAnnouncements() fiber.Handler {
return func(c *fiber.Ctx) error {
visible := announcements.Visible(s.Announcements.Get(), time.Now())
return c.Status(fiber.StatusOK).JSON(visible)
}
}
2 changes: 1 addition & 1 deletion api/api/blocklists_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (s *BlocklistsAPISuite) server() *APIServer {
gen := mocks.NewGeneratoridgen(s.T())
mail := mocks.NewMaileremail(s.T())
short := urlshort.NewURLShortener()
srv, err := NewServer(s.cfg, testService, s.db, cache, gen, s.v, mail, short, nil)
srv, err := NewServer(s.cfg, testService, s.db, cache, gen, s.v, mail, short, nil, nil)
s.Require().NoError(err)
srv.RegisterRoutes()
return srv
Expand Down
1 change: 1 addition & 0 deletions api/api/pasession_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (suite *PASessionAPITestSuite) createTestServer() *APIServer {
mockMailer,
mockShortener,
nil,
nil,
)
suite.Require().NoError(err, "Failed to create test server")
server.RegisterRoutes()
Expand Down
2 changes: 1 addition & 1 deletion api/api/query_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (s *QueryLogsAPIShortSuite) server() *APIServer {
gen := mocks.NewGeneratoridgen(s.T())
mail := mocks.NewMaileremail(s.T())
short := urlshort.NewURLShortener()
srv, err := NewServer(s.cfg, testService, s.db, cache, gen, s.v, mail, short, nil)
srv, err := NewServer(s.cfg, testService, s.db, cache, gen, s.v, mail, short, nil, nil)
s.Require().NoError(err)
srv.RegisterRoutes()
return srv
Expand Down
8 changes: 7 additions & 1 deletion api/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/ivpn/dns/api/config"
"github.com/ivpn/dns/api/db"
_ "github.com/ivpn/dns/api/docs"
"github.com/ivpn/dns/api/internal/announcements"
"github.com/ivpn/dns/api/internal/email"
"github.com/ivpn/dns/api/internal/idgen"
"github.com/ivpn/dns/api/internal/middleware"
Expand All @@ -38,10 +39,11 @@ type APIServer struct {
Mailer email.Mailer
Shortener *urlshort.URLShortener
ServicesCatalog *servicescatalogcache.Loader
Announcements *announcements.Loader
}

// NewServer inititiates database connection and sets up API endpoints
func NewServer(config *config.Config, service service.Service, db db.Db, cache cache.Cache, idGen idgen.Generator, apiValidator *validator.APIValidator, email email.Mailer, shortener *urlshort.URLShortener, servicesCatalog *servicescatalogcache.Loader) (*APIServer, error) {
func NewServer(config *config.Config, service service.Service, db db.Db, cache cache.Cache, idGen idgen.Generator, apiValidator *validator.APIValidator, email email.Mailer, shortener *urlshort.URLShortener, servicesCatalog *servicescatalogcache.Loader, announcementsLoader *announcements.Loader) (*APIServer, error) {
app := fiber.New(fiber.Config{
ServerHeader: "modDNS API",
AppName: "modDNS API",
Expand All @@ -59,6 +61,7 @@ func NewServer(config *config.Config, service service.Service, db db.Db, cache c
Mailer: email,
Shortener: shortener,
ServicesCatalog: servicesCatalog,
Announcements: announcementsLoader,
}

middleware.InitLimitConfig(config.API)
Expand Down Expand Up @@ -142,6 +145,9 @@ func (s *APIServer) RegisterRoutes() {
// Unrestricted short URL endpoint
v1.Get("/short/:code", middleware.NewLimit(10, 1*time.Minute), s.downloadMobileConfigFromLink())

// Public announcements endpoint (no auth, rate limited only)
v1.Get("/announcements", middleware.NewLimit(60, 1*time.Minute), s.getAnnouncements())

// Verification endpoints
verify.Post("/reset-password", middleware.NewLimit(10, 1*time.Minute), s.verifyPasswordReset())

Expand Down
1 change: 1 addition & 0 deletions api/api/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ func (suite *SubscriptionAPITestSuite) createTestServer() *APIServer {
mockMailer,
mockShortener,
nil,
nil,
)
suite.Require().NoError(err, "Failed to create test server")
server.RegisterRoutes()
Expand Down
47 changes: 47 additions & 0 deletions api/cmd/announcements-validate/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Command announcements-validate parses an announcements feed file with the
// same parser the modDNS API uses at runtime and exits non-zero if it is
// invalid. It is used by the announcements content branch's CI so authors get
// parser-accurate feedback before merge.
package main

import (
"fmt"
"os"

"github.com/ivpn/dns/api/internal/announcements"
)

// warnBytes is the soft threshold (80% of the runtime feed cap) above which the
// file is large enough to warrant pruning before it risks truncation.
const warnBytes = announcements.MaxBodyBytes * 8 / 10

func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: announcements-validate <path-to-announcements.md>")
os.Exit(2)
}
path := os.Args[1]

data, err := os.ReadFile(path) //nolint:gosec // G703: path is a trusted CLI/CI argument (the announcements file to validate), not untrusted input
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot read %s: %v\n", path, err)
os.Exit(1)
}

anns, err := announcements.Parse(data)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid announcements file %s:\n %v\n", path, err)
os.Exit(1)
}

fmt.Printf("OK: %s parsed cleanly (%d announcement(s))\n", path, len(anns))

// Soft size check: the API truncates the feed at MaxBodyBytes, so bytes past
// that point silently disappear. Warn (but don't fail) as the file
// approaches the cap, giving authors a heads-up to prune expired entries.
if size := int64(len(data)); size >= warnBytes {
fmt.Fprintf(os.Stderr,
"warning: %s is %d KB, %.0f%% of the %d KB feed limit — bytes past the limit are silently dropped at runtime; prune expired announcements soon\n",
path, size/1024, float64(size)/float64(announcements.MaxBodyBytes)*100, announcements.MaxBodyBytes/1024)
}
}
9 changes: 9 additions & 0 deletions api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type ServiceConfig struct {
MaxCredentials int
ServicesCatalogPath string
ServicesCatalogReloadEvery time.Duration
AnnouncementsURL string
AnnouncementsReloadEvery time.Duration

// Startup migrations (removable after all environments are migrated)
MigrateSubscriptionUUIDSubtype bool
Expand Down Expand Up @@ -171,6 +173,11 @@ func New() (*Config, error) {
return nil, err
}

announcementsReloadEvery, err := time.ParseDuration(envOrDefault("ANNOUNCEMENTS_RELOAD", "5m"))
if err != nil {
return nil, err
}

// Warn about missing security-critical configuration.
if os.Getenv("API_PSK") == "" {
log.Warn().Msg("API_PSK is not set; the subscription provisioning endpoint will reject all requests")
Expand Down Expand Up @@ -256,6 +263,8 @@ func New() (*Config, error) {
MaxCredentials: maxCredentials,
ServicesCatalogPath: servicesCatalogPath,
ServicesCatalogReloadEvery: servicesCatalogReloadEvery,
AnnouncementsURL: os.Getenv("ANNOUNCEMENTS_URL"),
AnnouncementsReloadEvery: announcementsReloadEvery,
MigrateSubscriptionUUIDSubtype: parseBoolEnv("MIGRATE_SUBSCRIPTION_UUID_SUBTYPE"),
},
Sentry: &SentryConfig{
Expand Down
87 changes: 87 additions & 0 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,29 @@ const docTemplate = `{
}
}
},
"/api/v1/announcements": {
"get": {
"description": "Get the list of currently published announcements. Public endpoint (no authentication).",
"produces": [
"application/json"
],
"tags": [
"Announcements"
],
"summary": "Get announcements",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/announcements.Announcement"
}
}
}
}
}
},
"/api/v1/blocklists": {
"get": {
"security": [
Expand Down Expand Up @@ -2397,6 +2420,70 @@ const docTemplate = `{
}
},
"definitions": {
"announcements.Announcement": {
"type": "object",
"properties": {
"body": {
"type": "string"
},
"category": {
"$ref": "#/definitions/announcements.Category"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "string"
},
"link": {
"type": "string"
},
"pinned": {
"type": "boolean"
},
"published_at": {
"type": "string"
},
"severity": {
"$ref": "#/definitions/announcements.Severity"
},
"title": {
"type": "string"
}
}
},
"announcements.Category": {
"type": "string",
"enum": [
"news",
"feature",
"maintenance",
"incident",
"security",
"policy"
],
"x-enum-varnames": [
"CategoryNews",
"CategoryFeature",
"CategoryMaintenance",
"CategoryIncident",
"CategorySecurity",
"CategoryPolicy"
]
},
"announcements.Severity": {
"type": "string",
"enum": [
"info",
"warning",
"critical"
],
"x-enum-varnames": [
"SeverityInfo",
"SeverityWarning",
"SeverityCritical"
]
},
"api.BlocklistsUpdates": {
"type": "object",
"required": [
Expand Down
Loading
Loading