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:
18
.env.example
Normal file
18
.env.example
Normal 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
46
.gitignore
vendored
Normal 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
34
Dockerfile
Normal 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
203
README.md
Normal 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
29
docker-compose.yml
Normal 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
42
go.mod
Normal 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
90
go.sum
Normal 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
83
internal/config/config.go
Normal 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
219
internal/database/db.go
Normal 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
184
internal/dns/technitium.go
Normal 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
|
||||
}
|
||||
229
internal/handlers/handlers.go
Normal file
229
internal/handlers/handlers.go
Normal 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
|
||||
}
|
||||
112
internal/middleware/ratelimit.go
Normal file
112
internal/middleware/ratelimit.go
Normal 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
35
internal/models/space.go
Normal 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
258
web/static/css/style.css
Normal 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
135
web/static/js/app.js
Normal 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
118
web/templates/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user