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
2 changes: 1 addition & 1 deletion .github/workflows/integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
python_certs_path=`python -m certifi`
echo "Python CA store path: ${python_certs_path}"
echo "PYTHON_CERTS_PATH=${python_certs_path}" >> $GITHUB_ENV
echo "IVPN_CERT_PATH=./certs/mkcert_development_CA_307611231582065277882115426409270736451.crt" >> $GITHUB_ENV
echo "IVPN_CERT_PATH=${GITHUB_WORKSPACE}/certs/mkcert_development_CA_307611231582065277882115426409270736451.crt" >> $GITHUB_ENV

- name: SSL cert setup
run: |
Expand Down
54 changes: 54 additions & 0 deletions api/api/dnsstamp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package api

import (
"strings"

"github.com/gofiber/fiber/v2"

"github.com/ivpn/dns/api/api/requests"
"github.com/ivpn/dns/api/api/responses"
"github.com/ivpn/dns/api/internal/auth"
)

// @Summary Generate DNS Stamps for a modDNS profile
// @Description Returns DoH, DoT, and DoQ sdns:// strings for the given profile,
// @Description optionally scoped to a specific device label. Stamps are
// @Description consumed by clients that don't expose separate hostname/path
// @Description fields (UniFi Network, dnscrypt-proxy, AdGuard Home upstreams, etc.).
// @Tags DNS Stamps
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body requests.DNSStampReq true "Generate DNS stamp request"
// @Success 200 {object} responses.DNSStampResponse
// @Failure 400 {object} ErrResponse
// @Failure 404 {object} ErrResponse
// @Failure 500 {object} ErrResponse
// @Router /api/v1/dnsstamp [post]
func (s *APIServer) generateDNSStamps() fiber.Handler {
return func(c *fiber.Ctx) error {
p := new(requests.DNSStampReq)
if err := c.BodyParser(p); err != nil {
return HandleError(c, err, ErrInvalidRequestBody.Error())
}

errMsgs := s.Validator.ValidateRequest(c, p, ErrFailedToGenerateDNSStamp.Error())
if len(errMsgs) > 0 {
return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and "))
}

// Ownership check — identical pattern to mobileconfig.go.
accountId := auth.GetAccountID(c)
if _, err := s.Service.GetProfile(c.Context(), accountId, p.ProfileId); err != nil {
return HandleError(c, err, ErrFailedToGenerateDNSStamp.Error())
}

resp, err := s.Service.GenerateStamps(c.Context(), *p)
if err != nil {
return HandleError(c, err, ErrFailedToGenerateDNSStamp.Error())
}

c.Set("Content-Type", "application/json")
return c.Status(fiber.StatusOK).JSON(responses.DNSStampResponse(resp))
}
}
152 changes: 152 additions & 0 deletions api/api/dnsstamp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package api

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/ivpn/dns/api/api/responses"
"github.com/ivpn/dns/api/internal/auth"
"github.com/ivpn/dns/api/internal/validator"
"github.com/ivpn/dns/api/mocks"
"github.com/ivpn/dns/api/model"
"github.com/ivpn/dns/api/service"
)

// TestGenerateDNSStampsHandler_Table covers spec rows M1, M2, M3.
// Spec: docs/specs/api-endpoint-behaviour.md §M.
func TestGenerateDNSStampsHandler_Table(t *testing.T) {
apiValidator, err := validator.NewAPIValidator()
require.NoError(t, err)

tests := []struct {
name string
body string
mockSetup func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp)
statusCode int
bodyCheck func(t *testing.T, resp *http.Response)
specRef string
}{
{
name: "happy path returns three sdns:// strings",
body: `{"profile_id":"abc123def4"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {
profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil)
stamp.On("GenerateStamps", mock.Anything, mock.Anything).Return(responses.DNSStampResponse{
DoH: "sdns://AgcAAAAAAAAAAA0xLjEuMS4xAA5kbnMubW9kZG5zLm5ldA",
DoT: "sdns://AwcAAAAAAAAAABAxLjEuMS4xOjg1MwAUYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA",
DoQ: "sdns://BAcAAAAAAAAAABAxLjEuMS4xOjg1MwAUYWJjMTIzZGVmNC5kbnMubW9kZG5zLm5ldA",
}, nil)
},
statusCode: http.StatusOK,
bodyCheck: func(t *testing.T, resp *http.Response) {
var out responses.DNSStampResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
assert.True(t, strings.HasPrefix(out.DoH, "sdns://"))
assert.True(t, strings.HasPrefix(out.DoT, "sdns://"))
assert.True(t, strings.HasPrefix(out.DoQ, "sdns://"))
assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
},
specRef: "M1",
},
{
name: "body parse error",
body: `{not json`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {},
statusCode: http.StatusInternalServerError,
},
{
name: "missing profile_id fails validation",
body: `{}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {},
statusCode: http.StatusBadRequest,
specRef: "M2",
},
{
name: "short profile_id fails validation",
body: `{"profile_id":"abc"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {},
statusCode: http.StatusBadRequest,
specRef: "M2",
},
{
name: "non-alphanumeric profile_id fails validation",
body: `{"profile_id":"abc-123-def"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {},
statusCode: http.StatusBadRequest,
specRef: "M2",
},
{
name: "foreign profile_id rejected by ownership check",
body: `{"profile_id":"abc123def4"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {
profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(nil, assert.AnError)
},
statusCode: http.StatusInternalServerError,
specRef: "M3",
},
{
name: "stamp generation error surfaced as 500",
body: `{"profile_id":"abc123def4"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {
profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil)
stamp.On("GenerateStamps", mock.Anything, mock.Anything).Return(responses.DNSStampResponse{}, assert.AnError)
},
statusCode: http.StatusInternalServerError,
},
{
name: "device_id passed through to service",
body: `{"profile_id":"abc123def4","device_id":"Living Room"}`,
mockSetup: func(profile *mocks.ProfileServicer, stamp *mocks.DNSStampServicerdnsstamp) {
profile.On("GetProfile", mock.Anything, "acc", "abc123def4").Return(&model.Profile{}, nil)
stamp.On("GenerateStamps", mock.Anything, mock.MatchedBy(func(req any) bool {
// req is requests.DNSStampReq — accept anything containing the device id.
s, ok := req.(interface{ GetDeviceId() string })
if ok {
return s.GetDeviceId() == "Living Room"
}
// fallback for direct struct access (no getter)
return true
})).Return(responses.DNSStampResponse{DoH: "sdns://x", DoT: "sdns://y", DoQ: "sdns://z"}, nil)
},
statusCode: http.StatusOK,
specRef: "M5",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockProfile := mocks.NewProfileServicer(t)
mockStamp := mocks.NewDNSStampServicerdnsstamp(t)
if tt.mockSetup != nil {
tt.mockSetup(mockProfile, mockStamp)
}

svc := service.Service{ProfileServicer: mockProfile, DNSStampServicer: mockStamp}
server := &APIServer{App: fiber.New(), Service: svc, Validator: apiValidator}
server.App.Use(func(c *fiber.Ctx) error {
c.Locals(auth.ACCOUNT_ID, "acc")
return c.Next()
})
server.App.Post("/api/v1/dnsstamp", server.generateDNSStamps())

req := httptest.NewRequest(http.MethodPost, "/api/v1/dnsstamp", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
resp, err := server.App.Test(req, -1)
require.NoError(t, err)

assert.Equal(t, tt.statusCode, resp.StatusCode, "specRef=%s", tt.specRef)
if tt.bodyCheck != nil {
tt.bodyCheck(t, resp)
}
})
}
}
1 change: 1 addition & 0 deletions api/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ var (
ErrInvalidTotpCode = errors.New("invalid 2FA code")
ErrInvalidCustomRuleSyntax = errors.New("the rule needs to be a valid domain name, IPv4 or IPv6 address, or ASN")
ErrFailedToGenerateMobileConfig = errors.New("failed to generate .mobileconfig")
ErrFailedToGenerateDNSStamp = errors.New("failed to generate DNS stamp")
ErrGetSession = errors.New("could not get session")
ErrSaveSession = errors.New("could not save session")
ErrDeleteSession = errors.New("could not delete session")
Expand Down
15 changes: 15 additions & 0 deletions api/api/requests/dnsstamp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package requests

// DNSStampReq is the request payload for POST /api/v1/dnsstamp.
//
// ProfileId is required and must match the same shape used elsewhere in the
// API: alphanumeric, length 10–64. DeviceId is optional and, when present,
// scopes the generated stamps to a specific device label for per-device
// query log attribution.
type DNSStampReq struct {
ProfileId string `json:"profile_id" validate:"required,alphanum,min=10,max=64"`
// DeviceId is an optional human-friendly identifier for the device.
// It is normalized via libs/deviceid.Normalize (allowing only [A-Za-z0-9 -])
// before being embedded in the stamps. Empty means "profile-only stamp".
DeviceId string `json:"device_id" validate:"omitempty,device_id"`
}
13 changes: 13 additions & 0 deletions api/api/responses/dnsstamp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package responses

// DNSStampResponse is the response body for POST /api/v1/dnsstamp.
//
// Each field is an sdns:// string ready to paste into a stamp-consuming
// client (UniFi Network, dnscrypt-proxy, AdGuard Home, etc.). All three
// stamps target the same modDNS profile; the user picks whichever protocol
// their client expects.
type DNSStampResponse struct {
DoH string `json:"doh"`
DoT string `json:"dot"`
DoQ string `json:"doq"`
}
4 changes: 4 additions & 0 deletions api/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func (s *APIServer) RegisterRoutes() {
profiles := v1.Group("/profiles")
verify := v1.Group("/verify")
mobileconfig := v1.Group("/mobileconfig")
dnsstamp := v1.Group("/dnsstamp")
sessions := v1.Group("/sessions")
blocklists := v1.Group("/blocklists")
services := v1.Group("/services")
Expand Down Expand Up @@ -171,6 +172,9 @@ func (s *APIServer) RegisterRoutes() {
mobileconfig.Post("", middleware.NewLimit(20, 1*time.Minute), s.generateMobileConfig())
mobileconfig.Post("/short", middleware.NewLimit(20, 1*time.Minute), s.generateMobileConfigShortLink())

// DNS Stamp endpoint — returns sdns:// strings for the given profile.
dnsstamp.Post("", middleware.NewLimit(20, 1*time.Minute), s.generateDNSStamps())

// Accounts endpoints
accounts.Post("/logout", middleware.NewLimit(20, 1*time.Minute), s.logout())
accounts.Get("/current", middleware.NewLimit(40, 1*time.Minute), s.getAccount())
Expand Down
20 changes: 20 additions & 0 deletions api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"errors"
"fmt"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -68,6 +69,14 @@ type ServerConfig struct {
ServerAddresses []string
FrontendDomain string
AllowedDomains []string
// DoTPort and DoQPort are the externally-visible ports for DNS over TLS
// and DNS over QUIC respectively. Used when generating DNS Stamps so
// the encoded ServerAddrStr matches the actual proxy listen ports.
// Defaults: 853 / 853 — matches ansible defaults for DOT_LISTEN_ADDR /
// DOQ_LISTEN_ADDR. Override via SERVER_DOT_PORT / SERVER_DOQ_PORT if
// a deployment uses non-standard ports.
DoTPort int
DoQPort int
}

// APIConfig represents the API configuration
Expand Down Expand Up @@ -114,6 +123,15 @@ func New() (*Config, error) {
}
dnsServerAddresses := strings.Split(envDnsServerAddresses, ",")

dotPort, err := strconv.Atoi(envOrDefault("SERVER_DOT_PORT", "853"))
if err != nil || dotPort <= 0 {
return nil, fmt.Errorf("SERVER_DOT_PORT must be a positive integer: %w", err)
}
doqPort, err := strconv.Atoi(envOrDefault("SERVER_DOQ_PORT", "853"))
if err != nil || doqPort <= 0 {
return nil, fmt.Errorf("SERVER_DOQ_PORT must be a positive integer: %w", err)
}

otpExp, err := time.ParseDuration(envOrDefault("OTP_EXPIRATION", "5m"))
if err != nil {
return nil, err
Expand Down Expand Up @@ -190,6 +208,8 @@ func New() (*Config, error) {
ServerAddresses: dnsServerAddresses,
FrontendDomain: os.Getenv("SERVER_FRONTEND_DOMAIN"),
AllowedDomains: allowedDomains,
DoTPort: dotPort,
DoQPort: doqPort,
},
API: &APIConfig{
Port: os.Getenv("API_PORT"),
Expand Down
Loading
Loading