commit 2470f121e22e633938b0fb5e8e95c2132fda6a8e Author: Tanishq Dubey Date: Sun Feb 1 16:37:09 2026 -0500 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ccf706e --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3189e38 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..29af244 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c6bbcd --- /dev/null +++ b/README.md @@ -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 +``` + +### 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 +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=` - Check subdomain availability +- `POST /api/claim` - Claim a subdomain (returns token once) + +### DynDNS2 Protocol +- `GET /api/nic/update?hostname=&myip=` + - Auth: Basic auth with username `none` and password = token + - Returns: `good `, `nochg `, `badauth`, `nohost`, `911` + +## Router Configuration + +### UniFi / UDM + +``` +Service: dyndns +Hostname: your-space.space.dws.rip +Username: none +Password: +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: +``` + +### 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 +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:" \ + "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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..967cb59 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7926329 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22bbf9e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9fbc878 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..cefd567 --- /dev/null +++ b/internal/database/db.go @@ -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 +} diff --git a/internal/dns/technitium.go b/internal/dns/technitium.go new file mode 100644 index 0000000..263caf5 --- /dev/null +++ b/internal/dns/technitium.go @@ -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 +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..00d6069 --- /dev/null +++ b/internal/handlers/handlers.go @@ -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 +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..e9b3127 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -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] +} diff --git a/internal/models/space.go b/internal/models/space.go new file mode 100644 index 0000000..a855b29 --- /dev/null +++ b/internal/models/space.go @@ -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 +} diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..a0ff6e1 --- /dev/null +++ b/web/static/css/style.css @@ -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%; + } +} diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..15bfc19 --- /dev/null +++ b/web/static/js/app.js @@ -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); + }); + } +}); diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..bc6777f --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,118 @@ + + + + + + DWS Dynamic DNS + + + +
+
+

🌐 DWS Dynamic DNS

+

Claim your space under {{.zone}}

+
+ +
+
+

Claim Your Space

+
+
+ + .{{.zone}} +
+
+ +
+
+ + + + + + +
+ +
+

DWS Dynamic DNS Service

+
+
+ + + +