Initial commit: DDNS service with NIC V2 protocol support

Features:
- Token-based subdomain claiming
- NIC V2 (DynDNS2) protocol implementation
- Technitium DNS integration
- Rate limiting (10 req/min IP, 1 req/min token)
- Web UI for space claiming
- Docker/Docker Compose support
- Compatible with UniFi, pfSense, EdgeRouter

Module: git.dws.rip/DWS/dyn
This commit is contained in:
2026-02-01 16:37:09 -05:00
commit 2470f121e2
16 changed files with 1835 additions and 0 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Technitium DNS Configuration (Required)
TECHNITIUM_URL=https://dns.dws.rip
# Authentication - Use EITHER token OR username/password
TECHNITIUM_TOKEN=your-api-token-here
# TECHNITIUM_USERNAME=admin
# TECHNITIUM_PASSWORD=your-password
# Domain Configuration (Optional)
BASE_DOMAIN=dws.rip
SPACE_SUBDOMAIN=space
# Rate Limiting (Optional)
RATE_LIMIT_PER_IP=10
RATE_LIMIT_PER_TOKEN=1
# Network (Optional)
TRUSTED_PROXIES=

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
server
dyn
# Test binary
*.test
# Output of the go coverage tool
*.out
# Database
dyn.db
*.db
*.db-journal
*.db-shm
*.db-wal
# Data directory
data/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Dependencies (keep go.sum)
vendor/
# Temporary files
*.tmp
*.temp

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Build stage
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache gcc musl-dev sqlite-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o server cmd/server/main.go
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates sqlite-libs
WORKDIR /app
COPY --from=builder /app/server .
COPY --from=builder /app/web ./web
VOLUME ["/data"]
ENV DATABASE_PATH=/data/dyn.db \
SERVER_PORT=8080 \
RATE_LIMIT_PER_IP=10 \
RATE_LIMIT_PER_TOKEN=1
EXPOSE 8080
CMD ["./server"]

203
README.md Normal file
View File

@@ -0,0 +1,203 @@
# DWS Dynamic DNS Service
A self-hosted Dynamic DNS (DDNS) provider built with Go, featuring a minimalist web UI and NIC V2 (DynDNS2) protocol support for router compatibility.
## Overview
This service allows users to claim subdomains under `space.dws.rip` and update them via the standard DynDNS2 protocol, compatible with UniFi, pfSense, EdgeRouters, and other consumer routers.
## Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Router │ ──────► │ DWS Bridge │ ──────► │ Technitium DNS │
│ (DynDNS2) │ HTTP │ (Go) │ API │ (Authoritative)│
└─────────────┘ └──────────────┘ └─────────────────┘
┌──────────────┐
│ SQLite │
│ (token→sub) │
└──────────────┘
```
## Features
- **Token-based authentication** - No user accounts, just secure tokens
- **NIC V2 Protocol** - Compatible with most routers
- **Automatic wildcard records** - `*.your-space.space.dws.rip` also works
- **Rate limiting** - 10 req/min per IP, 1 req/min per token
- **Clean web UI** - Simple space claiming interface
- **Router config examples** - UniFi, pfSense, cURL snippets
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Running Technitium DNS server with API access
- DNS delegation for `space.dws.rip` pointing to Technitium
### DNS Setup
In your parent `dws.rip` zone:
```text
space.dws.rip. IN NS dns.dws.rip.
dns.dws.rip. IN A <TECHNITIUM_IP>
```
### Configuration
Create a `.env` file:
```bash
# Required: Technitium DNS API endpoint
TECHNITIUM_URL=https://dns.dws.rip
# Authentication (choose one method)
# Method 1: API Token (recommended)
TECHNITIUM_TOKEN=your-api-token-here
# Method 2: Username/Password
TECHNITIUM_USERNAME=admin
TECHNITIUM_PASSWORD=your-password
# Optional: Domain configuration
BASE_DOMAIN=dws.rip
SPACE_SUBDOMAIN=space
# Optional: Rate limiting
RATE_LIMIT_PER_IP=10
RATE_LIMIT_PER_TOKEN=1
# Optional: Trusted proxies (comma-separated)
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
```
### Deployment
```bash
# Clone and start
git clone <repo>
cd dyn
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down
```
## API Endpoints
### Web UI
- `GET /` - Claim space interface
### API
- `GET /api/check?subdomain=<name>` - Check subdomain availability
- `POST /api/claim` - Claim a subdomain (returns token once)
### DynDNS2 Protocol
- `GET /api/nic/update?hostname=<fqdn>&myip=<ip>`
- Auth: Basic auth with username `none` and password = token
- Returns: `good <ip>`, `nochg <ip>`, `badauth`, `nohost`, `911`
## Router Configuration
### UniFi / UDM
```
Service: dyndns
Hostname: your-space.space.dws.rip
Username: none
Password: <your-token>
Server: dyn.dws.rip/api/nic/update
```
### pfSense / OPNsense
```
Service Type: Custom
Update URL: https://dyn.dws.rip/api/nic/update?hostname=%h&myip=%i
Username: none
Password: <your-token>
```
### EdgeRouter
```bash
set service dns dynamic interface eth0 service custom-dyn host-name your-space.space.dws.rip
set service dns dynamic interface eth0 service custom-dyn login none
set service dns dynamic interface eth0 service custom-dyn password <your-token>
set service dns dynamic interface eth0 service custom-dyn protocol dyndns2
set service dns dynamic interface eth0 service custom-dyn server dyn.dws.rip
```
### Manual (cURL)
```bash
curl -u "none:<your-token>" \
"https://dyn.dws.rip/api/nic/update?hostname=your-space.space.dws.rip&myip=1.2.3.4"
```
## Database Schema
```sql
CREATE TABLE spaces (
token TEXT PRIMARY KEY,
subdomain TEXT UNIQUE NOT NULL,
last_ip TEXT,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## Security
- **32-byte random tokens** - Generated with `crypto/rand`
- **Token displayed once** - No recovery mechanism (claim new space if lost)
- **Rate limiting** - Prevents brute force and abuse
- **No PII stored** - Just token-to-subdomain mapping
- **Input validation** - Subdomains validated (alphanumeric + hyphen only)
## Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `TECHNITIUM_URL` | Yes | - | Technitium API URL |
| `TECHNITIUM_TOKEN` | No* | - | API token for auth |
| `TECHNITIUM_USERNAME` | No* | - | Basic auth username |
| `TECHNITIUM_PASSWORD` | No* | - | Basic auth password |
| `BASE_DOMAIN` | No | `dws.rip` | Root domain |
| `SPACE_SUBDOMAIN` | No | `space` | Subdomain for user spaces |
| `DATABASE_PATH` | No | `./dyn.db` | SQLite database path |
| `SERVER_PORT` | No | `8080` | HTTP server port |
| `RATE_LIMIT_PER_IP` | No | `10` | Requests per minute per IP |
| `RATE_LIMIT_PER_TOKEN` | No | `1` | Updates per minute per token |
| `TRUSTED_PROXIES` | No | - | Comma-separated CIDRs |
*Either `TECHNITIUM_TOKEN` OR both `TECHNITIUM_USERNAME` and `TECHNITIUM_PASSWORD` required.
## Development
```bash
# Local development
go mod tidy
go run cmd/server/main.go
# Build
go build -o server cmd/server/main.go
# Test
go test ./...
```
## License
MIT
## Credits
Inspired by [benjaminbear/docker-ddns-server](https://github.com/benjaminbear/docker-ddns-server)

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: '3.8'
services:
dyn:
build: .
container_name: dyn-ddns
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
environment:
- SERVER_PORT=8080
- DATABASE_PATH=/data/dyn.db
- TECHNITIUM_URL=${TECHNITIUM_URL}
- TECHNITIUM_TOKEN=${TECHNITIUM_TOKEN}
- TECHNITIUM_USERNAME=${TECHNITIUM_USERNAME:-}
- TECHNITIUM_PASSWORD=${TECHNITIUM_PASSWORD:-}
- BASE_DOMAIN=${BASE_DOMAIN:-dws.rip}
- SPACE_SUBDOMAIN=${SPACE_SUBDOMAIN:-space}
- RATE_LIMIT_PER_IP=${RATE_LIMIT_PER_IP:-10}
- RATE_LIMIT_PER_TOKEN=${RATE_LIMIT_PER_TOKEN:-1}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s

42
go.mod Normal file
View File

@@ -0,0 +1,42 @@
module git.dws.rip/DWS/dyn
go 1.23.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/mattn/go-sqlite3 v1.14.24
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

90
go.sum Normal file
View File

@@ -0,0 +1,90 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@@ -0,0 +1,83 @@
package config
import (
"os"
"strconv"
"strings"
)
type Config struct {
ServerPort string
DatabasePath string
TechnitiumURL string
TechnitiumUsername string
TechnitiumPassword string
TechnitiumToken string
BaseDomain string
SpaceSubdomain string
RateLimitPerIP int
RateLimitPerToken int
TrustedProxies []string
}
func Load() *Config {
cfg := &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: getEnv("DATABASE_PATH", "./dyn.db"),
TechnitiumURL: getEnv("TECHNITIUM_URL", ""),
TechnitiumUsername: getEnv("TECHNITIUM_USERNAME", ""),
TechnitiumPassword: getEnv("TECHNITIUM_PASSWORD", ""),
TechnitiumToken: getEnv("TECHNITIUM_TOKEN", ""),
BaseDomain: getEnv("BASE_DOMAIN", "dws.rip"),
SpaceSubdomain: getEnv("SPACE_SUBDOMAIN", "space"),
RateLimitPerIP: getEnvAsInt("RATE_LIMIT_PER_IP", 10),
RateLimitPerToken: getEnvAsInt("RATE_LIMIT_PER_TOKEN", 1),
TrustedProxies: getEnvAsSlice("TRUSTED_PROXIES", []string{}),
}
return cfg
}
func (c *Config) Validate() []string {
var errors []string
if c.TechnitiumURL == "" {
errors = append(errors, "TECHNITIUM_URL is required")
}
if c.TechnitiumToken == "" && (c.TechnitiumUsername == "" || c.TechnitiumPassword == "") {
errors = append(errors, "Either TECHNITIUM_TOKEN or TECHNITIUM_USERNAME/PASSWORD must be provided")
}
return errors
}
func (c *Config) GetZone() string {
if c.SpaceSubdomain == "" {
return c.BaseDomain
}
return c.SpaceSubdomain + "." + c.BaseDomain
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvAsSlice(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
return strings.Split(value, ",")
}
return defaultValue
}

219
internal/database/db.go Normal file
View File

@@ -0,0 +1,219 @@
package database
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
"git.dws.rip/DWS/dyn/internal/models"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return db, nil
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS spaces (
token TEXT PRIMARY KEY,
subdomain TEXT UNIQUE NOT NULL,
last_ip TEXT,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subdomain ON spaces(subdomain);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
space := &models.Space{
Token: token,
Subdomain: subdomain,
CreatedAt: time.Now(),
}
query := `
INSERT INTO spaces (token, subdomain, last_ip, updated_at, created_at)
VALUES (?, ?, ?, ?, ?)
`
_, err = db.conn.ExecContext(ctx, query,
space.Token,
space.Subdomain,
space.LastIP,
space.UpdatedAt,
space.CreatedAt,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, fmt.Errorf("subdomain already taken")
}
return nil, fmt.Errorf("failed to create space: %w", err)
}
return space, nil
}
func (db *DB) GetSpaceByToken(ctx context.Context, token string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE token = ?
`
row := db.conn.QueryRowContext(ctx, query, token)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) GetSpaceBySubdomain(ctx context.Context, subdomain string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE subdomain = ?
`
row := db.conn.QueryRowContext(ctx, query, subdomain)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) UpdateSpaceIP(ctx context.Context, token string, ip string) error {
query := `
UPDATE spaces
SET last_ip = ?, updated_at = ?
WHERE token = ?
`
_, err := db.conn.ExecContext(ctx, query, ip, time.Now(), token)
if err != nil {
return fmt.Errorf("failed to update space IP: %w", err)
}
return nil
}
func (db *DB) SubdomainExists(ctx context.Context, subdomain string) (bool, error) {
query := `SELECT 1 FROM spaces WHERE subdomain = ?`
row := db.conn.QueryRowContext(ctx, query, subdomain)
var exists int
err := row.Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check subdomain: %w", err)
}
return true, nil
}
func generateToken() (string, error) {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate key value")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || containsInternal(s, substr))
}
func containsInternal(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

184
internal/dns/technitium.go Normal file
View File

@@ -0,0 +1,184 @@
package dns
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type Client struct {
baseURL string
token string
username string
password string
httpClient *http.Client
}
type AddRecordRequest struct {
Domain string `json:"domain"`
Type string `json:"type"`
IPAddress string `json:"ipAddress,omitempty"`
Overwrite bool `json:"overwrite"`
TTL int `json:"ttl,omitempty"`
}
type AddRecordResponse struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Error string `json:"errorMessage,omitempty"`
}
type APIResponse struct {
Status string `json:"status"`
Response json.RawMessage `json:"response"`
Error *APIError `json:"error,omitempty"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func NewClient(baseURL, token, username, password string) *Client {
return &Client{
baseURL: baseURL,
token: token,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *Client) AddARecord(zone, hostname, ip string, ttl int) error {
domain := fmt.Sprintf("%s.%s", hostname, zone)
reqBody := AddRecordRequest{
Domain: domain,
Type: "A",
IPAddress: ip,
Overwrite: true,
}
if ttl > 0 {
reqBody.TTL = ttl
}
return c.addRecord(reqBody)
}
func (c *Client) AddWildcardARecord(zone, hostname, ip string, ttl int) error {
domain := fmt.Sprintf("*.%s.%s", hostname, zone)
reqBody := AddRecordRequest{
Domain: domain,
Type: "A",
IPAddress: ip,
Overwrite: true,
}
if ttl > 0 {
reqBody.TTL = ttl
}
return c.addRecord(reqBody)
}
func (c *Client) addRecord(req AddRecordRequest) error {
endpoint := fmt.Sprintf("%s/api/dns/records/add", c.baseURL)
formData := url.Values{}
formData.Set("domain", req.Domain)
formData.Set("type", req.Type)
formData.Set("ipAddress", req.IPAddress)
formData.Set("overwrite", fmt.Sprintf("%t", req.Overwrite))
if req.TTL > 0 {
formData.Set("ttl", fmt.Sprintf("%d", req.TTL))
}
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.token != "" {
httpReq.Header.Set("Authorization", "Basic "+c.token)
} else if c.username != "" && c.password != "" {
httpReq.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
var apiResp APIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if apiResp.Status != "ok" {
if apiResp.Error != nil {
return fmt.Errorf("API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
}
return fmt.Errorf("API error: status not ok")
}
return nil
}
func (c *Client) DeleteRecord(zone, hostname, recordType string) error {
endpoint := fmt.Sprintf("%s/api/dns/records/delete", c.baseURL)
domain := fmt.Sprintf("%s.%s", hostname, zone)
if hostname == "" || hostname == "@" {
domain = zone
}
formData := url.Values{}
formData.Set("domain", domain)
formData.Set("type", recordType)
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.token != "" {
httpReq.Header.Set("Authorization", "Basic "+c.token)
} else if c.username != "" && c.password != "" {
httpReq.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
return nil
}

View File

@@ -0,0 +1,229 @@
package handlers
import (
"context"
"encoding/base64"
"net"
"net/http"
"strings"
"time"
"git.dws.rip/DWS/dyn/internal/config"
"git.dws.rip/DWS/dyn/internal/database"
"git.dws.rip/DWS/dyn/internal/dns"
"git.dws.rip/DWS/dyn/internal/models"
"github.com/gin-gonic/gin"
)
type WebHandler struct {
db *database.DB
config *config.Config
}
func NewWebHandler(db *database.DB, cfg *config.Config) *WebHandler {
return &WebHandler{
db: db,
config: cfg,
}
}
func (h *WebHandler) Index(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"baseDomain": h.config.BaseDomain,
"spaceSubdomain": h.config.SpaceSubdomain,
"zone": h.config.GetZone(),
})
}
func (h *WebHandler) ClaimSpace(c *gin.Context) {
var req models.CreateSpaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
subdomain := strings.ToLower(strings.TrimSpace(req.Subdomain))
if !isValidSubdomain(subdomain) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
exists, err := h.db.SubdomainExists(ctx, subdomain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain availability"})
return
}
if exists {
c.JSON(http.StatusConflict, gin.H{"error": "Subdomain already taken"})
return
}
space, err := h.db.CreateSpace(ctx, subdomain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create space"})
return
}
resp := models.CreateSpaceResponse{
Token: space.Token,
Subdomain: space.Subdomain,
FQDN: space.GetFQDN(h.config.GetZone()),
CreatedAt: space.CreatedAt,
}
c.JSON(http.StatusCreated, resp)
}
func (h *WebHandler) CheckSubdomain(c *gin.Context) {
subdomain := strings.ToLower(strings.TrimSpace(c.Query("subdomain")))
if subdomain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Subdomain parameter required"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
exists, err := h.db.SubdomainExists(ctx, subdomain)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain"})
return
}
c.JSON(http.StatusOK, gin.H{
"available": !exists,
"subdomain": subdomain,
})
}
func isValidSubdomain(subdomain string) bool {
if len(subdomain) < 3 || len(subdomain) > 63 {
return false
}
if subdomain[0] == '-' || subdomain[len(subdomain)-1] == '-' {
return false
}
for _, ch := range subdomain {
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
return false
}
}
return true
}
type DynDNSHandler struct {
db *database.DB
dns *dns.Client
config *config.Config
}
func NewDynDNSHandler(db *database.DB, dnsClient *dns.Client, cfg *config.Config) *DynDNSHandler {
return &DynDNSHandler{
db: db,
dns: dnsClient,
config: cfg,
}
}
func (h *DynDNSHandler) Update(c *gin.Context) {
token, err := extractBasicAuthPassword(c)
if err != nil {
c.String(http.StatusUnauthorized, "badauth")
return
}
hostname := c.Query("hostname")
myip := c.Query("myip")
if hostname == "" {
c.String(http.StatusBadRequest, "nohost")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
space, err := h.db.GetSpaceByToken(ctx, token)
if err != nil {
c.String(http.StatusServiceUnavailable, "911")
return
}
if space == nil {
c.String(http.StatusUnauthorized, "badauth")
return
}
expectedFQDN := space.GetFQDN(h.config.GetZone())
if hostname != expectedFQDN {
c.String(http.StatusBadRequest, "nohost")
return
}
if myip == "" {
myip = c.ClientIP()
}
if net.ParseIP(myip) == nil {
c.String(http.StatusBadRequest, "dnserr")
return
}
zone := h.config.GetZone()
err = h.dns.AddARecord(zone, space.Subdomain, myip, 300)
if err != nil {
c.String(http.StatusServiceUnavailable, "911")
return
}
err = h.dns.AddWildcardARecord(zone, space.Subdomain, myip, 300)
if err != nil {
c.String(http.StatusServiceUnavailable, "911")
return
}
if space.LastIP == myip {
c.String(http.StatusOK, "nochg %s", myip)
return
}
err = h.db.UpdateSpaceIP(ctx, token, myip)
if err != nil {
c.String(http.StatusServiceUnavailable, "911")
return
}
c.String(http.StatusOK, "good %s", myip)
}
func extractBasicAuthPassword(c *gin.Context) (string, error) {
auth := c.GetHeader("Authorization")
if auth == "" {
return "", nil
}
const prefix = "Basic "
if !strings.HasPrefix(auth, prefix) {
return "", nil
}
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
if err != nil {
return "", err
}
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) != 2 {
return "", nil
}
return parts[1], nil
}

View File

@@ -0,0 +1,112 @@
package middleware
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type RateLimiter struct {
ipLimits map[string]*RateLimitEntry
tokenLimits map[string]*RateLimitEntry
mu sync.RWMutex
limitPerIP int
limitPerToken int
}
type RateLimitEntry struct {
Count int
ResetTime time.Time
}
func NewRateLimiter(perIP, perToken int) *RateLimiter {
return &RateLimiter{
ipLimits: make(map[string]*RateLimitEntry),
tokenLimits: make(map[string]*RateLimitEntry),
limitPerIP: perIP,
limitPerToken: perToken,
}
}
func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
rl.mu.Lock()
entry, exists := rl.ipLimits[ip]
now := time.Now()
if !exists || now.After(entry.ResetTime) {
rl.ipLimits[ip] = &RateLimitEntry{
Count: 1,
ResetTime: now.Add(time.Minute),
}
rl.mu.Unlock()
c.Next()
return
}
if entry.Count >= rl.limitPerIP {
rl.mu.Unlock()
c.String(http.StatusTooManyRequests, "rate limit exceeded")
c.Abort()
return
}
entry.Count++
rl.mu.Unlock()
c.Next()
}
}
func (rl *RateLimiter) RateLimitByToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token == "" {
c.Next()
return
}
rl.mu.Lock()
entry, exists := rl.tokenLimits[token]
now := time.Now()
if !exists || now.After(entry.ResetTime) {
rl.tokenLimits[token] = &RateLimitEntry{
Count: 1,
ResetTime: now.Add(time.Minute),
}
rl.mu.Unlock()
c.Next()
return
}
if entry.Count >= rl.limitPerToken {
rl.mu.Unlock()
c.String(http.StatusTooManyRequests, "rate limit exceeded")
c.Abort()
return
}
entry.Count++
rl.mu.Unlock()
c.Next()
}
}
func extractToken(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Basic" {
return ""
}
return parts[1]
}

35
internal/models/space.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import (
"time"
)
type Space struct {
Token string `json:"token" db:"token"`
Subdomain string `json:"subdomain" db:"subdomain"`
LastIP string `json:"last_ip" db:"last_ip"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type CreateSpaceRequest struct {
Subdomain string `json:"subdomain" binding:"required,alphanumdash,min=3,max=63"`
}
type CreateSpaceResponse struct {
Token string `json:"token"`
Subdomain string `json:"subdomain"`
FQDN string `json:"fqdn"`
CreatedAt time.Time `json:"created_at"`
}
type SpaceInfo struct {
Subdomain string `json:"subdomain"`
LastIP string `json:"last_ip"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Space) GetFQDN(zone string) string {
return s.Subdomain + "." + zone
}

258
web/static/css/style.css Normal file
View File

@@ -0,0 +1,258 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
padding: 40px 0;
color: white;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
header p {
font-size: 1.2rem;
opacity: 0.9;
}
.card {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.card h2 {
margin-bottom: 20px;
color: #667eea;
}
.input-group {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
}
.input-group input {
flex: 1;
border: none;
background: transparent;
font-size: 1.1rem;
outline: none;
padding: 5px;
}
.domain-suffix {
color: #666;
font-size: 1.1rem;
}
.status {
margin: 10px 0;
font-size: 0.9rem;
min-height: 20px;
}
.status.available {
color: #22c55e;
}
.status.taken {
color: #ef4444;
}
button {
width: 100%;
padding: 14px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #5a67d8;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
margin-top: 10px;
}
.btn-secondary:hover {
background: #e5e5e5;
}
.hidden {
display: none !important;
}
.token-display {
background: #f0f9ff;
border: 2px solid #667eea;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.token {
display: block;
word-break: break-all;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
background: white;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
.fqdn-display {
margin-top: 20px;
}
.fqdn-display code {
display: block;
font-size: 1.1rem;
color: #667eea;
margin-top: 5px;
}
.config-grid {
display: grid;
gap: 15px;
margin: 20px 0;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
padding: 12px;
border-radius: 6px;
}
.config-item label {
font-weight: 600;
color: #666;
}
.config-item code {
font-family: 'Courier New', monospace;
background: white;
padding: 4px 8px;
border-radius: 4px;
max-width: 60%;
overflow-x: auto;
}
.examples {
margin-top: 30px;
}
.examples h3 {
margin-bottom: 15px;
color: #667eea;
}
.example {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.example h4 {
margin-bottom: 10px;
color: #333;
}
.example ul {
list-style: none;
}
.example li {
padding: 5px 0;
border-bottom: 1px solid #e5e5e5;
}
.example li:last-child {
border-bottom: none;
}
.example pre {
background: #1a1a1a;
color: #fff;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
font-size: 0.85rem;
margin: 10px 0;
}
.card.error {
border-left: 4px solid #ef4444;
}
.card.error h2 {
color: #ef4444;
}
footer {
text-align: center;
padding: 20px;
color: white;
opacity: 0.8;
}
@media (max-width: 600px) {
header h1 {
font-size: 1.8rem;
}
.card {
padding: 20px;
}
.config-item {
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.config-item code {
max-width: 100%;
width: 100%;
}
}

135
web/static/js/app.js Normal file
View File

@@ -0,0 +1,135 @@
document.addEventListener('DOMContentLoaded', function() {
const claimForm = document.getElementById('claim-form');
const subdomainInput = document.getElementById('subdomain');
const availabilityStatus = document.getElementById('availability-status');
const claimBtn = document.getElementById('claim-btn');
const claimSection = document.getElementById('claim-section');
const successSection = document.getElementById('success-section');
const errorSection = document.getElementById('error-section');
const routerConfig = document.getElementById('router-config');
let checkTimeout = null;
subdomainInput.addEventListener('input', function() {
const value = this.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
this.value = value;
clearTimeout(checkTimeout);
availabilityStatus.textContent = '';
availabilityStatus.className = 'status';
claimBtn.disabled = true;
if (value.length >= 3) {
checkTimeout = setTimeout(() => checkAvailability(value), 300);
}
});
async function checkAvailability(subdomain) {
try {
const response = await fetch(`/api/check?subdomain=${encodeURIComponent(subdomain)}`);
const data = await response.json();
if (data.available) {
availabilityStatus.textContent = '✓ Available';
availabilityStatus.className = 'status available';
claimBtn.disabled = false;
} else {
availabilityStatus.textContent = '✗ Already taken';
availabilityStatus.className = 'status taken';
claimBtn.disabled = true;
}
} catch (error) {
console.error('Error checking availability:', error);
}
}
claimForm.addEventListener('submit', async function(e) {
e.preventDefault();
const subdomain = subdomainInput.value;
claimBtn.disabled = true;
claimBtn.textContent = 'Claiming...';
try {
const response = await fetch('/api/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdomain: subdomain }),
});
const data = await response.json();
if (response.ok) {
showSuccess(data);
} else {
showError(data.error || 'Failed to claim space');
}
} catch (error) {
showError('Network error. Please try again.');
}
claimBtn.disabled = false;
claimBtn.textContent = 'Claim Space';
});
function showSuccess(data) {
claimSection.classList.add('hidden');
successSection.classList.remove('hidden');
routerConfig.classList.remove('hidden');
document.getElementById('token-value').textContent = data.token;
document.getElementById('fqdn-value').textContent = data.fqdn;
const updateUrl = `https://dyn.${data.fqdn.split('.').slice(-2).join('.')}/api/nic/update?hostname=%h&myip=%i`;
document.getElementById('update-url').textContent = updateUrl;
document.querySelectorAll('.hostname-placeholder').forEach(el => {
el.textContent = data.fqdn;
});
document.querySelectorAll('.update-url-placeholder').forEach(el => {
el.textContent = updateUrl;
});
const curlCmd = `curl -u "none:${data.token}" "https://dyn.${data.fqdn.split('.').slice(-2).join('.')}/api/nic/update?hostname=${data.fqdn}"`;
document.getElementById('curl-example').textContent = curlCmd;
document.getElementById('copy-token').addEventListener('click', function() {
copyToClipboard(data.token);
this.textContent = 'Copied!';
setTimeout(() => this.textContent = 'Copy Token', 2000);
});
document.getElementById('copy-curl').addEventListener('click', function() {
copyToClipboard(curlCmd);
this.textContent = 'Copied!';
setTimeout(() => this.textContent = 'Copy', 2000);
});
}
function showError(message) {
claimSection.classList.add('hidden');
errorSection.classList.remove('hidden');
document.getElementById('error-message').textContent = message;
}
document.getElementById('try-again').addEventListener('click', function() {
errorSection.classList.add('hidden');
claimSection.classList.remove('hidden');
subdomainInput.value = '';
subdomainInput.focus();
});
function copyToClipboard(text) {
navigator.clipboard.writeText(text).catch(function() {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
});
}
});

118
web/templates/index.html Normal file
View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DWS Dynamic DNS</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🌐 DWS Dynamic DNS</h1>
<p>Claim your space under {{.zone}}</p>
</header>
<main>
<section id="claim-section" class="card">
<h2>Claim Your Space</h2>
<form id="claim-form">
<div class="input-group">
<input type="text"
id="subdomain"
name="subdomain"
placeholder="your-name"
maxlength="63"
autocomplete="off"
pattern="[a-z0-9-]+"
required>
<span class="domain-suffix">.{{.zone}}</span>
</div>
<div id="availability-status" class="status"></div>
<button type="submit" id="claim-btn" disabled>Claim Space</button>
</form>
</section>
<section id="success-section" class="card hidden">
<h2>✅ Space Claimed!</h2>
<div class="token-display">
<p><strong>Your Token (save this!):</strong></p>
<code id="token-value" class="token"></code>
<button id="copy-token" class="btn-secondary">Copy Token</button>
</div>
<div class="fqdn-display">
<p>Your hostname:</p>
<code id="fqdn-value"></code>
</div>
</section>
<section id="router-config" class="card hidden">
<h2>Router Configuration</h2>
<p>Use these settings in your router's Dynamic DNS configuration:</p>
<div class="config-grid">
<div class="config-item">
<label>Service:</label>
<code>Custom</code>
</div>
<div class="config-item">
<label>Update URL:</label>
<code id="update-url"></code>
</div>
<div class="config-item">
<label>Username:</label>
<code>none</code>
</div>
<div class="config-item">
<label>Password:</label>
<code id="password-value">(your token above)</code>
</div>
</div>
<div class="examples">
<h3>Example Configurations</h3>
<div class="example">
<h4>UniFi / UDM</h4>
<ul>
<li><strong>Service:</strong> dyndns</li>
<li><strong>Hostname:</strong> <span class="hostname-placeholder"></span></li>
<li><strong>Username:</strong> none</li>
<li><strong>Password:</strong> <em>your token</em></li>
<li><strong>Server:</strong> dyn.{{.baseDomain}}/api/nic/update</li>
</ul>
</div>
<div class="example">
<h4>pfSense / OPNsense</h4>
<ul>
<li><strong>Service Type:</strong> Custom</li>
<li><strong>Update URL:</strong> <span class="update-url-placeholder"></span></li>
<li><strong>Username:</strong> none</li>
<li><strong>Password:</strong> <em>your token</em></li>
</ul>
</div>
<div class="example">
<h4>cURL Test</h4>
<pre><code id="curl-example"></code></pre>
<button id="copy-curl" class="btn-secondary">Copy</button>
</div>
</div>
</section>
<section id="error-section" class="card hidden error">
<h2>❌ Error</h2>
<p id="error-message"></p>
<button id="try-again" class="btn-secondary">Try Again</button>
</section>
</main>
<footer>
<p>DWS Dynamic DNS Service</p>
</footer>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>