Compare commits
11 Commits
2470f121e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee187ff1c0 | |||
| 01694f66a8 | |||
| ad494fa623 | |||
| f6d016677b | |||
| 967cc5aa7e | |||
| 133c94bfb9 | |||
| 058fec14eb | |||
| 63c3c10f2b | |||
| c5279243c0 | |||
| f96aaf1e96 | |||
| f3f1c0a0c8 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,8 +4,8 @@
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
server
|
/server
|
||||||
dyn
|
/dyn
|
||||||
|
|
||||||
# Test binary
|
# Test binary
|
||||||
*.test
|
*.test
|
||||||
@@ -44,3 +44,7 @@ vendor/
|
|||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Kubernetes secrets (never commit these!)
|
||||||
|
k8s/overlays/*/secrets.yaml
|
||||||
|
!k8s/overlays/*/secrets.example.yaml
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
RUN apk add --no-cache gcc musl-dev sqlite-dev
|
||||||
|
|
||||||
|
|||||||
58
Makefile
Normal file
58
Makefile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
.PHONY: build test test-unit test-integration test-compose run clean docker-build docker-push help
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
BINARY_NAME=dyn-server
|
||||||
|
DOCKER_IMAGE=git.dws.rip/DWS/dyn
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo "Available targets:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
build: ## Build the binary
|
||||||
|
go build -o $(BINARY_NAME) cmd/server/main.go
|
||||||
|
|
||||||
|
test: ## Run all tests
|
||||||
|
go test ./... -v
|
||||||
|
|
||||||
|
test-unit: ## Run unit tests only
|
||||||
|
go test ./internal/... -v
|
||||||
|
|
||||||
|
test-integration: ## Run integration tests (with mock server)
|
||||||
|
go test ./tests/integration -v -run 'TestIntegration_[^C]'
|
||||||
|
|
||||||
|
test-compose: ## Run containerized integration tests with podman/docker-compose
|
||||||
|
@echo "Running compose integration tests..."
|
||||||
|
bash tests/integration/compose_test.sh
|
||||||
|
|
||||||
|
test-compose-go: ## Run Go-based compose integration tests
|
||||||
|
RUN_COMPOSE_TESTS=true go test ./tests/integration -v -run TestComposeIntegration -timeout 120s
|
||||||
|
|
||||||
|
run: build ## Build and run the server
|
||||||
|
./$(BINARY_NAME)
|
||||||
|
|
||||||
|
dev: ## Run in development mode
|
||||||
|
go run cmd/server/main.go
|
||||||
|
|
||||||
|
docker-build: ## Build Docker image
|
||||||
|
docker build -t $(DOCKER_IMAGE):$(VERSION) -t $(DOCKER_IMAGE):latest .
|
||||||
|
|
||||||
|
docker-push: docker-build ## Push Docker image
|
||||||
|
docker push $(DOCKER_IMAGE):$(VERSION)
|
||||||
|
docker push $(DOCKER_IMAGE):latest
|
||||||
|
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
rm -f $(BINARY_NAME)
|
||||||
|
go clean
|
||||||
|
|
||||||
|
deps: ## Download dependencies
|
||||||
|
go mod download
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
lint: ## Run linter (requires golangci-lint)
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
fmt: ## Format Go code
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
121
README.md
121
README.md
@@ -77,9 +77,11 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
|
|||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
|
#### Docker Compose (Recommended for single node)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone and start
|
# Clone and start
|
||||||
git clone <repo>
|
git clone https://git.dws.rip/DWS/dyn.git
|
||||||
cd dyn
|
cd dyn
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
@@ -90,6 +92,44 @@ docker-compose logs -f
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Kubernetes / K3s
|
||||||
|
|
||||||
|
For production deployments on Kubernetes or K3s clusters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://git.dws.rip/DWS/dyn.git
|
||||||
|
cd dyn
|
||||||
|
|
||||||
|
# Create your secrets file
|
||||||
|
cp k8s/overlays/production/secrets.example.yaml k8s/overlays/production/secrets.yaml
|
||||||
|
# Edit secrets.yaml with your actual Technitium credentials
|
||||||
|
|
||||||
|
# Deploy with kustomize
|
||||||
|
kubectl apply -k k8s/overlays/production
|
||||||
|
|
||||||
|
# Or with kubectl 1.14+ (built-in kustomize)
|
||||||
|
kubectl apply -k k8s/overlays/production
|
||||||
|
|
||||||
|
# Check deployment status
|
||||||
|
kubectl get pods -n dyn-ddns
|
||||||
|
kubectl logs -n dyn-ddns -l app=dyn-ddns
|
||||||
|
|
||||||
|
# Delete deployment
|
||||||
|
kubectl delete -k k8s/overlays/production
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kustomize Overlays:**
|
||||||
|
- `k8s/overlays/production` - Production setup (2 replicas, higher resource limits)
|
||||||
|
- `k8s/overlays/staging` - Staging environment (1 replica, relaxed rate limits)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Cert-manager (for TLS certificates via Let's Encrypt)
|
||||||
|
- Traefik or NGINX ingress controller
|
||||||
|
- Persistent storage class (for SQLite database)
|
||||||
|
|
||||||
|
See [k8s/README.md](k8s/README.md) for detailed Kubernetes deployment documentation.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
@@ -190,8 +230,85 @@ go run cmd/server/main.go
|
|||||||
# Build
|
# Build
|
||||||
go build -o server cmd/server/main.go
|
go build -o server cmd/server/main.go
|
||||||
|
|
||||||
# Test
|
# Run all tests
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
go test ./... -v
|
||||||
|
|
||||||
|
# Run specific test package
|
||||||
|
go test ./internal/handlers -v
|
||||||
|
go test ./internal/database -v
|
||||||
|
go test ./tests/integration -v
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
go test ./... -cover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The project includes comprehensive tests:
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- **config**: Configuration loading and validation (11 tests)
|
||||||
|
- **database**: Database operations and models (9 tests)
|
||||||
|
- **handlers**: Handler logic, custom filters, and validation (36 tests)
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
Full end-to-end tests using:
|
||||||
|
- **Mock Technitium Server**: Simulates DNS API without external dependencies
|
||||||
|
- **SQLite**: In-memory/temp database for isolated testing
|
||||||
|
- **Gin Test Server**: HTTP testing without network
|
||||||
|
|
||||||
|
Integration tests cover:
|
||||||
|
- Space claiming workflow
|
||||||
|
- Subdomain availability checking
|
||||||
|
- Profanity filtering
|
||||||
|
- Custom DWS/Tanishq Dubey trademark filtering
|
||||||
|
- DynDNS2 protocol updates
|
||||||
|
- Full end-to-end workflows
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
```
|
||||||
|
internal/config/config_test.go # Config tests
|
||||||
|
internal/database/db_test.go # Database tests
|
||||||
|
internal/handlers/handlers_test.go # Handler unit tests
|
||||||
|
internal/handlers/validation.go # Custom validators
|
||||||
|
internal/testutil/mock_technitium.go # Mock DNS server
|
||||||
|
tests/integration/integration_test.go # Integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Integration Tests
|
||||||
|
```bash
|
||||||
|
# Run all integration tests
|
||||||
|
go test ./tests/integration -v
|
||||||
|
|
||||||
|
# Run specific integration test
|
||||||
|
go test ./tests/integration -v -run TestIntegration_FullWorkflow
|
||||||
|
|
||||||
|
# Run with timeout
|
||||||
|
go test ./tests/integration -v -timeout 30s
|
||||||
|
|
||||||
|
# Run containerized integration tests with podman/docker-compose
|
||||||
|
make test-compose
|
||||||
|
|
||||||
|
# Or using the shell script directly
|
||||||
|
bash tests/integration/compose_test.sh
|
||||||
|
|
||||||
|
# Run Go-based compose tests (requires RUN_COMPOSE_TESTS=true)
|
||||||
|
RUN_COMPOSE_TESTS=true go test ./tests/integration -v -run TestComposeIntegration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Make
|
||||||
|
```bash
|
||||||
|
make help # Show all available targets
|
||||||
|
make build # Build the binary
|
||||||
|
make test # Run all tests
|
||||||
|
make test-unit # Run unit tests only
|
||||||
|
make test-integration # Run integration tests
|
||||||
|
make test-compose # Run containerized tests
|
||||||
|
make run # Build and run the server
|
||||||
|
make dev # Run in development mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
129
cmd/server/main.go
Normal file
129
cmd/server/main.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"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/handlers"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/middleware"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
if errs := cfg.Validate(); len(errs) > 0 {
|
||||||
|
for _, err := range errs {
|
||||||
|
log.Printf("Configuration error: %s", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
dnsClient := dns.NewClient(
|
||||||
|
cfg.TechnitiumURL,
|
||||||
|
cfg.TechnitiumToken,
|
||||||
|
cfg.TechnitiumUsername,
|
||||||
|
cfg.TechnitiumPassword,
|
||||||
|
)
|
||||||
|
|
||||||
|
rateLimiter := middleware.NewRateLimiter(cfg.RateLimitPerIP, cfg.RateLimitPerToken)
|
||||||
|
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
// Add request logging middleware
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
raw := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
latency := time.Since(start)
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
method := c.Request.Method
|
||||||
|
statusCode := c.Writer.Status()
|
||||||
|
|
||||||
|
if raw != "" {
|
||||||
|
path = path + "?" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[%s] %s %s %d %v", clientIP, method, path, statusCode, latency)
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(cfg.TrustedProxies) > 0 {
|
||||||
|
router.SetTrustedProxies(cfg.TrustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.Static("/static", "./web/static")
|
||||||
|
router.LoadHTMLGlob("web/templates/*")
|
||||||
|
|
||||||
|
webHandler := handlers.NewWebHandler(db, cfg)
|
||||||
|
dynHandler := handlers.NewDynDNSHandler(db, dnsClient, cfg)
|
||||||
|
debugHandler := handlers.NewDebugHandler(db, dnsClient, cfg)
|
||||||
|
|
||||||
|
router.GET("/", webHandler.Index)
|
||||||
|
|
||||||
|
// Debug/health endpoints (no rate limiting)
|
||||||
|
router.GET("/health", debugHandler.Health)
|
||||||
|
router.GET("/debug/config", debugHandler.ConfigDebug)
|
||||||
|
router.GET("/debug/stats", debugHandler.Stats)
|
||||||
|
router.GET("/debug/test-dns", debugHandler.TestDNS)
|
||||||
|
|
||||||
|
api := router.Group("/api")
|
||||||
|
api.Use(rateLimiter.RateLimitByIP())
|
||||||
|
{
|
||||||
|
api.GET("/check", webHandler.CheckSubdomain)
|
||||||
|
api.POST("/claim", webHandler.ClaimSpace)
|
||||||
|
|
||||||
|
nic := api.Group("/nic")
|
||||||
|
nic.Use(rateLimiter.RateLimitByToken())
|
||||||
|
nic.GET("/update", dynHandler.Update)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := cfg.ServerPort
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("Server starting on port %s", port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("Shutting down server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("Server forced to shutdown: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Server exited")
|
||||||
|
}
|
||||||
69
docker-compose.integration.yml
Normal file
69
docker-compose.integration.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
technitium:
|
||||||
|
image: docker.io/technitium/dns-server:11.0.2
|
||||||
|
container_name: technitium-dns
|
||||||
|
hostname: dns
|
||||||
|
ports:
|
||||||
|
- "5380:5380"
|
||||||
|
volumes:
|
||||||
|
- technitium-data:/etc/dns/config
|
||||||
|
- ./tests/integration/technitium-init.sh:/init.sh:ro
|
||||||
|
environment:
|
||||||
|
- DNS_SERVER_DOMAIN=dns.test.rip
|
||||||
|
- DNS_SERVER_ADMIN_PASSWORD=admin123
|
||||||
|
- DNS_SERVER_ADMIN_PASSWORD_FILE=
|
||||||
|
- DNS_SERVER_WEB_SERVICE_HTTP_PORT=5380
|
||||||
|
- DNS_SERVER_WEB_SERVICE_ENABLE_HTTPS=false
|
||||||
|
- DNS_SERVER_WEB_SERVICE_USE_SELF_SIGNED_CERT=false
|
||||||
|
- DNS_SERVER_OPTIONAL_PROTOCOL_DNS_OVER_HTTP=false
|
||||||
|
- DNS_SERVER_RECURSION=AllowOnlyForPrivateNetworks
|
||||||
|
- DNS_SERVER_LOG_USING_LOCAL_TIME=true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5380/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- dyn-test
|
||||||
|
|
||||||
|
dyn:
|
||||||
|
build: .
|
||||||
|
container_name: dyn-ddns-test
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- dyn-data:/data
|
||||||
|
environment:
|
||||||
|
- SERVER_PORT=8080
|
||||||
|
- DATABASE_PATH=/data/dyn.db
|
||||||
|
- TECHNITIUM_URL=http://technitium:5380
|
||||||
|
- TECHNITIUM_TOKEN=dns-api-token-12345
|
||||||
|
- TECHNITIUM_USERNAME=
|
||||||
|
- TECHNITIUM_PASSWORD=
|
||||||
|
- BASE_DOMAIN=test.rip
|
||||||
|
- SPACE_SUBDOMAIN=space
|
||||||
|
- RATE_LIMIT_PER_IP=1000
|
||||||
|
- RATE_LIMIT_PER_TOKEN=1000
|
||||||
|
- TRUSTED_PROXIES=
|
||||||
|
depends_on:
|
||||||
|
technitium:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- dyn-test
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
technitium-data:
|
||||||
|
dyn-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dyn-test:
|
||||||
|
driver: bridge
|
||||||
21
go.mod
21
go.mod
@@ -1,16 +1,19 @@
|
|||||||
module git.dws.rip/DWS/dyn
|
module git.dws.rip/DWS/dyn
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/TwiN/go-away v1.8.1
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
@@ -25,18 +28,20 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.42.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.44.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
31
go.sum
31
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/TwiN/go-away v1.8.1 h1:zbbr0ISBkDSbnUFHrnRUhbCR/7+9ONMWtIi1BiQWX8Y=
|
||||||
|
github.com/TwiN/go-away v1.8.1/go.mod h1:nSQEvd/FYBNmnC27RGJdPi91LXYMG8SrRc1o1w+VmKY=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
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 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 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -67,23 +69,24 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
372
internal/config/config_test.go
Normal file
372
internal/config/config_test.go
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad(t *testing.T) {
|
||||||
|
// Clean up any existing env vars
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Set test values
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://test.dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "test-token-12345")
|
||||||
|
os.Setenv("BASE_DOMAIN", "test.rip")
|
||||||
|
os.Setenv("SPACE_SUBDOMAIN", "dyn")
|
||||||
|
os.Setenv("RATE_LIMIT_PER_IP", "20")
|
||||||
|
os.Setenv("RATE_LIMIT_PER_TOKEN", "5")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"TechnitiumURL", cfg.TechnitiumURL, "https://test.dns.example.com"},
|
||||||
|
{"TechnitiumToken", cfg.TechnitiumToken, "test-token-12345"},
|
||||||
|
{"BaseDomain", cfg.BaseDomain, "test.rip"},
|
||||||
|
{"SpaceSubdomain", cfg.SpaceSubdomain, "dyn"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.expected {
|
||||||
|
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test numeric values
|
||||||
|
if cfg.RateLimitPerIP != 20 {
|
||||||
|
t.Errorf("RateLimitPerIP = %v, want 20", cfg.RateLimitPerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RateLimitPerToken != 5 {
|
||||||
|
t.Errorf("RateLimitPerToken = %v, want 5", cfg.RateLimitPerToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"ServerPort", cfg.ServerPort, "8080"},
|
||||||
|
{"DatabasePath", cfg.DatabasePath, "./dyn.db"},
|
||||||
|
{"BaseDomain", cfg.BaseDomain, "dws.rip"},
|
||||||
|
{"SpaceSubdomain", cfg.SpaceSubdomain, "space"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.expected {
|
||||||
|
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default numeric values
|
||||||
|
if cfg.RateLimitPerIP != 10 {
|
||||||
|
t.Errorf("RateLimitPerIP default = %v, want 10", cfg.RateLimitPerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RateLimitPerToken != 1 {
|
||||||
|
t.Errorf("RateLimitPerToken default = %v, want 1", cfg.RateLimitPerToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupEnv func()
|
||||||
|
wantErrors int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid config with token",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "valid-token")
|
||||||
|
},
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config with username/password",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_USERNAME", "admin")
|
||||||
|
os.Setenv("TECHNITIUM_PASSWORD", "secret")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
},
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing url",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Unsetenv("TECHNITIUM_URL")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "token")
|
||||||
|
},
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing auth",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
os.Unsetenv("TECHNITIUM_USERNAME")
|
||||||
|
os.Unsetenv("TECHNITIUM_PASSWORD")
|
||||||
|
},
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing everything",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Unsetenv("TECHNITIUM_URL")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
os.Unsetenv("TECHNITIUM_USERNAME")
|
||||||
|
os.Unsetenv("TECHNITIUM_PASSWORD")
|
||||||
|
},
|
||||||
|
wantErrors: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
tt.setupEnv()
|
||||||
|
cfg := Load()
|
||||||
|
errors := cfg.Validate()
|
||||||
|
|
||||||
|
if len(errors) != tt.wantErrors {
|
||||||
|
t.Errorf("Validate() returned %d errors, want %d: %v", len(errors), tt.wantErrors, errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetZone(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
base string
|
||||||
|
space string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with space subdomain",
|
||||||
|
base: "dws.rip",
|
||||||
|
space: "space",
|
||||||
|
expected: "space.dws.rip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without space subdomain",
|
||||||
|
base: "dws.rip",
|
||||||
|
space: "",
|
||||||
|
expected: "dws.rip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested subdomain",
|
||||||
|
base: "example.com",
|
||||||
|
space: "dyn.space",
|
||||||
|
expected: "dyn.space.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
BaseDomain: tt.base,
|
||||||
|
SpaceSubdomain: tt.space,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := cfg.GetZone()
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("GetZone() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
defaultValue string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "env set",
|
||||||
|
key: "TEST_KEY",
|
||||||
|
value: "test-value",
|
||||||
|
defaultValue: "default",
|
||||||
|
expected: "test-value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env not set",
|
||||||
|
key: "UNSET_TEST_KEY",
|
||||||
|
value: "",
|
||||||
|
defaultValue: "default",
|
||||||
|
expected: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(tt.key, tt.value)
|
||||||
|
defer os.Unsetenv(tt.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnv(tt.key, tt.defaultValue)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("getEnv() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvAsInt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
defaultValue int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid int",
|
||||||
|
value: "42",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid int",
|
||||||
|
value: "not-a-number",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value",
|
||||||
|
value: "",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative int",
|
||||||
|
value: "-5",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: -5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key := "TEST_INT_KEY"
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(key, tt.value)
|
||||||
|
defer os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnvAsInt(key, tt.defaultValue)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("getEnvAsInt() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvAsSlice(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
defaultValue []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single value",
|
||||||
|
value: "10.0.0.0/8",
|
||||||
|
defaultValue: []string{},
|
||||||
|
expected: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
value: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||||
|
defaultValue: []string{},
|
||||||
|
expected: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value",
|
||||||
|
value: "",
|
||||||
|
defaultValue: []string{"default"},
|
||||||
|
expected: []string{"default"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key := "TEST_SLICE_KEY"
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(key, tt.value)
|
||||||
|
defer os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnvAsSlice(key, tt.defaultValue)
|
||||||
|
|
||||||
|
if len(got) != len(tt.expected) {
|
||||||
|
t.Errorf("getEnvAsSlice() length = %v, want %v", len(got), len(tt.expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range got {
|
||||||
|
if v != tt.expected[i] {
|
||||||
|
t.Errorf("getEnvAsSlice()[%d] = %v, want %v", i, v, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnv removes all DYN-related env vars and returns a cleanup function
|
||||||
|
func cleanEnv() func() {
|
||||||
|
vars := []string{
|
||||||
|
"SERVER_PORT",
|
||||||
|
"DATABASE_PATH",
|
||||||
|
"TECHNITIUM_URL",
|
||||||
|
"TECHNITIUM_USERNAME",
|
||||||
|
"TECHNITIUM_PASSWORD",
|
||||||
|
"TECHNITIUM_TOKEN",
|
||||||
|
"BASE_DOMAIN",
|
||||||
|
"SPACE_SUBDOMAIN",
|
||||||
|
"RATE_LIMIT_PER_IP",
|
||||||
|
"RATE_LIMIT_PER_TOKEN",
|
||||||
|
"TRUSTED_PROXIES",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store old values
|
||||||
|
oldValues := make(map[string]string)
|
||||||
|
for _, v := range vars {
|
||||||
|
if val, ok := os.LookupEnv(v); ok {
|
||||||
|
oldValues[v] = val
|
||||||
|
os.Unsetenv(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return func() {
|
||||||
|
for _, v := range vars {
|
||||||
|
os.Unsetenv(v)
|
||||||
|
}
|
||||||
|
for v, val := range oldValues {
|
||||||
|
os.Setenv(v, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,42 @@ func (db *DB) Close() error {
|
|||||||
return db.conn.Close()
|
return db.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping checks if the database connection is alive
|
||||||
|
func (db *DB) Ping() error {
|
||||||
|
return db.conn.Ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns database statistics
|
||||||
|
func (db *DB) GetStats() (map[string]interface{}, error) {
|
||||||
|
stats := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Get total spaces count
|
||||||
|
var totalSpaces int
|
||||||
|
err := db.conn.QueryRow("SELECT COUNT(*) FROM spaces").Scan(&totalSpaces)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["total_spaces"] = totalSpaces
|
||||||
|
|
||||||
|
// Get spaces with IPs (active)
|
||||||
|
var activeSpaces int
|
||||||
|
err = db.conn.QueryRow("SELECT COUNT(*) FROM spaces WHERE last_ip IS NOT NULL").Scan(&activeSpaces)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["active_spaces"] = activeSpaces
|
||||||
|
|
||||||
|
// Get recently updated (last 24 hours)
|
||||||
|
var recentlyUpdated int
|
||||||
|
err = db.conn.QueryRow("SELECT COUNT(*) FROM spaces WHERE updated_at > datetime('now', '-1 day')").Scan(&recentlyUpdated)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["recently_updated_24h"] = recentlyUpdated
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
|
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
|
||||||
token, err := generateToken()
|
token, err := generateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
353
internal/database/db_test.go
Normal file
353
internal/database/db_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dws.rip/DWS/dyn/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) (*DB, func()) {
|
||||||
|
tmpFile := "/tmp/test_dyn_" + time.Now().Format("20060102150405") + ".db"
|
||||||
|
|
||||||
|
db, err := New(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
os.Remove(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSpace(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create valid space",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create another valid space",
|
||||||
|
subdomain: "office",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate subdomain",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.CreateSpace(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("CreateSpace() expected error but got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CreateSpace() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space == nil {
|
||||||
|
t.Errorf("CreateSpace() returned nil space")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.Subdomain != tt.subdomain {
|
||||||
|
t.Errorf("CreateSpace() subdomain = %v, want %v", space.Subdomain, tt.subdomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.Token == "" {
|
||||||
|
t.Errorf("CreateSpace() token is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceByToken(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "testspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing space",
|
||||||
|
token: created.Token,
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent token",
|
||||||
|
token: "invalid-token-12345",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
token: "",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.GetSpaceByToken(ctx, tt.token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetSpaceByToken() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantNil && space != nil {
|
||||||
|
t.Errorf("GetSpaceByToken() expected nil, got %v", space)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space == nil {
|
||||||
|
t.Errorf("GetSpaceByToken() expected space, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceBySubdomain(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "mytestspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "mytestspace",
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent subdomain",
|
||||||
|
subdomain: "nonexistent",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case sensitivity test",
|
||||||
|
subdomain: "MyTestSpace",
|
||||||
|
wantNil: true, // SQLite is case-sensitive by default
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.GetSpaceBySubdomain(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantNil && space != nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() expected nil, got %v", space)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space == nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() expected space, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space.Token != created.Token {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() token mismatch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSpaceIP(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "updatespace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
ip string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "update valid space",
|
||||||
|
token: created.Token,
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with different IP",
|
||||||
|
token: created.Token,
|
||||||
|
ip: "10.0.0.50",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update non-existent space",
|
||||||
|
token: "invalid-token",
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
wantErr: false, // SQL UPDATE with no match doesn't error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := db.UpdateSpaceIP(ctx, tt.token, tt.ip)
|
||||||
|
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Errorf("UpdateSpaceIP() expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("UpdateSpaceIP() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the update for valid token
|
||||||
|
if !tt.wantErr && tt.token == created.Token {
|
||||||
|
space, err := db.GetSpaceByToken(ctx, tt.token)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get space after update: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.LastIP != tt.ip {
|
||||||
|
t.Errorf("UpdateSpaceIP() IP = %v, want %v", space.LastIP, tt.ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubdomainExists(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
_, err := db.CreateSpace(ctx, "existingspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "existingspace",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent subdomain",
|
||||||
|
subdomain: "newspace",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty subdomain",
|
||||||
|
subdomain: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
exists, err := db.SubdomainExists(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SubdomainExists() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists != tt.want {
|
||||||
|
t.Errorf("SubdomainExists() = %v, want %v", exists, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenGeneration(t *testing.T) {
|
||||||
|
tokens := make(map[string]bool)
|
||||||
|
|
||||||
|
// Generate 100 tokens and ensure they're all unique
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateToken() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
t.Errorf("generateToken() returned empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 16 {
|
||||||
|
t.Errorf("generateToken() returned short token: %d chars", len(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens[token] {
|
||||||
|
t.Errorf("generateToken() returned duplicate token: %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens[token] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpaceModel(t *testing.T) {
|
||||||
|
space := &models.Space{
|
||||||
|
Token: "test-token",
|
||||||
|
Subdomain: "myspace",
|
||||||
|
LastIP: "192.168.1.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := space.GetFQDN("space.dws.rip")
|
||||||
|
if fqdn != "myspace.space.dws.rip" {
|
||||||
|
t.Errorf("GetFQDN() = %v, want %v", fqdn, "myspace.space.dws.rip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -90,7 +91,9 @@ func (c *Client) AddWildcardARecord(zone, hostname, ip string, ttl int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) addRecord(req AddRecordRequest) error {
|
func (c *Client) addRecord(req AddRecordRequest) error {
|
||||||
endpoint := fmt.Sprintf("%s/api/dns/records/add", c.baseURL)
|
// Build endpoint with token as query parameter for Technitium API
|
||||||
|
endpoint := fmt.Sprintf("%s/api/dns/records/add?token=%s", c.baseURL, c.token)
|
||||||
|
log.Printf("[DNS] Adding record: domain=%s, type=%s, ip=%s", req.Domain, req.Type, req.IPAddress)
|
||||||
|
|
||||||
formData := url.Values{}
|
formData := url.Values{}
|
||||||
formData.Set("domain", req.Domain)
|
formData.Set("domain", req.Domain)
|
||||||
@@ -103,44 +106,49 @@ func (c *Client) addRecord(req AddRecordRequest) error {
|
|||||||
|
|
||||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
|
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DNS] Failed to create request: %v", err)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
log.Printf("[DNS] Using token auth via query parameter")
|
||||||
|
|
||||||
if c.token != "" {
|
log.Printf("[DNS] Sending request to %s", endpoint)
|
||||||
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)
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DNS] Request failed: %v", err)
|
||||||
return fmt.Errorf("failed to execute request: %w", err)
|
return fmt.Errorf("failed to execute request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DNS] Failed to read response: %v", err)
|
||||||
return fmt.Errorf("failed to read response body: %w", err)
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[DNS] Response status: %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp APIResponse
|
var apiResp APIResponse
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
log.Printf("[DNS] Failed to parse response: %v", err)
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiResp.Status != "ok" {
|
if apiResp.Status != "ok" {
|
||||||
if apiResp.Error != nil {
|
if apiResp.Error != nil {
|
||||||
|
log.Printf("[DNS] API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
|
||||||
return fmt.Errorf("API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
|
return fmt.Errorf("API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
|
||||||
}
|
}
|
||||||
|
log.Printf("[DNS] API error: status not ok")
|
||||||
return fmt.Errorf("API error: status not ok")
|
return fmt.Errorf("API error: status not ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[DNS] Record added successfully")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
147
internal/handlers/custom_filter.go
Normal file
147
internal/handlers/custom_filter.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomFilter contains terms related to DWS and Tanishq Dubey that should be blocked
|
||||||
|
type CustomFilter struct {
|
||||||
|
blockedTerms []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomFilter creates a new custom filter with DWS and personal name variations
|
||||||
|
func NewCustomFilter() *CustomFilter {
|
||||||
|
return &CustomFilter{
|
||||||
|
blockedTerms: []string{
|
||||||
|
// DWS variations
|
||||||
|
"dws",
|
||||||
|
"dubey",
|
||||||
|
"dubeyweb",
|
||||||
|
"dubeywebservices",
|
||||||
|
"dubeyweb services",
|
||||||
|
"dubey-engineering",
|
||||||
|
"dubeyengineering",
|
||||||
|
"dwsengineering",
|
||||||
|
"dws-engineering",
|
||||||
|
"dws-engineering-llc",
|
||||||
|
"dwsengineeringllc",
|
||||||
|
"dwsllc",
|
||||||
|
"dws-llc",
|
||||||
|
"webservices",
|
||||||
|
"web-services",
|
||||||
|
"dubeycorp",
|
||||||
|
"dubey-corp",
|
||||||
|
"dubeyinc",
|
||||||
|
"dubey-inc",
|
||||||
|
|
||||||
|
// Tanishq Dubey variations
|
||||||
|
"tanishq",
|
||||||
|
"tanishqdubey",
|
||||||
|
"tanishq-dubey",
|
||||||
|
"tdubey",
|
||||||
|
"t-dubey",
|
||||||
|
"tanishq-d",
|
||||||
|
"tdub",
|
||||||
|
"tanish",
|
||||||
|
"dubey-t",
|
||||||
|
"dubeytanishq",
|
||||||
|
"dubey-tanishq",
|
||||||
|
|
||||||
|
// Leet speak variations
|
||||||
|
"dub3y",
|
||||||
|
"dub3yweb",
|
||||||
|
"t4nishq",
|
||||||
|
"t4n1shq",
|
||||||
|
"tan1shq",
|
||||||
|
"dub3y3ng1n33r1ng",
|
||||||
|
"dw5",
|
||||||
|
"dw$",
|
||||||
|
"dub3yc0rp",
|
||||||
|
|
||||||
|
// Common combinations
|
||||||
|
"dubeydns",
|
||||||
|
"dubey-ddns",
|
||||||
|
"dwsdns",
|
||||||
|
"dws-ddns",
|
||||||
|
"tanishqdns",
|
||||||
|
"tanishq-ddns",
|
||||||
|
"tdubeydns",
|
||||||
|
"tdubey-ddns",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlocked checks if the given text contains any blocked terms
|
||||||
|
func (cf *CustomFilter) IsBlocked(text string) bool {
|
||||||
|
normalized := cf.normalize(text)
|
||||||
|
|
||||||
|
for _, term := range cf.blockedTerms {
|
||||||
|
if strings.Contains(normalized, term) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize prepares text for comparison by:
|
||||||
|
// - Converting to lowercase
|
||||||
|
// - Removing common separators
|
||||||
|
// - Converting leet speak to normal text
|
||||||
|
func (cf *CustomFilter) normalize(text string) string {
|
||||||
|
// Convert to lowercase
|
||||||
|
text = strings.ToLower(text)
|
||||||
|
|
||||||
|
// Remove separators
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"-", "",
|
||||||
|
"_", "",
|
||||||
|
".", "",
|
||||||
|
" ", "",
|
||||||
|
)
|
||||||
|
text = replacer.Replace(text)
|
||||||
|
|
||||||
|
// Convert leet speak
|
||||||
|
text = cf.leetToNormal(text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// leetToNormal converts common leet speak characters to normal letters
|
||||||
|
func (cf *CustomFilter) leetToNormal(text string) string {
|
||||||
|
replacements := map[rune]rune{
|
||||||
|
'0': 'o',
|
||||||
|
'1': 'i',
|
||||||
|
'3': 'e',
|
||||||
|
'4': 'a',
|
||||||
|
'5': 's',
|
||||||
|
'6': 'g',
|
||||||
|
'7': 't',
|
||||||
|
'8': 'b',
|
||||||
|
'9': 'g',
|
||||||
|
'@': 'a',
|
||||||
|
'$': 's',
|
||||||
|
'!': 'i',
|
||||||
|
'|': 'i',
|
||||||
|
'+': 't',
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]rune, len(text))
|
||||||
|
for i, char := range text {
|
||||||
|
if replacement, ok := replacements[char]; ok {
|
||||||
|
result[i] = replacement
|
||||||
|
} else {
|
||||||
|
result[i] = unicode.ToLower(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockedTerms returns a copy of the blocked terms list (for testing/debugging)
|
||||||
|
func (cf *CustomFilter) GetBlockedTerms() []string {
|
||||||
|
terms := make([]string, len(cf.blockedTerms))
|
||||||
|
copy(terms, cf.blockedTerms)
|
||||||
|
return terms
|
||||||
|
}
|
||||||
169
internal/handlers/debug.go
Normal file
169
internal/handlers/debug.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dws.rip/DWS/dyn/internal/config"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/database"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/dns"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DebugHandler provides diagnostic endpoints for production troubleshooting
|
||||||
|
type DebugHandler struct {
|
||||||
|
db *database.DB
|
||||||
|
dns *dns.Client
|
||||||
|
config *config.Config
|
||||||
|
startTime string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDebugHandler creates a new debug handler
|
||||||
|
func NewDebugHandler(db *database.DB, dnsClient *dns.Client, cfg *config.Config) *DebugHandler {
|
||||||
|
return &DebugHandler{
|
||||||
|
db: db,
|
||||||
|
dns: dnsClient,
|
||||||
|
config: cfg,
|
||||||
|
startTime: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health returns detailed health status
|
||||||
|
func (h *DebugHandler) Health(c *gin.Context) {
|
||||||
|
health := gin.H{
|
||||||
|
"status": "healthy",
|
||||||
|
"version": getVersion(),
|
||||||
|
"uptime": h.startTime,
|
||||||
|
"go_version": runtime.Version(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
if err := h.db.Ping(); err != nil {
|
||||||
|
health["database"] = gin.H{"status": "unhealthy", "error": err.Error()}
|
||||||
|
health["status"] = "degraded"
|
||||||
|
} else {
|
||||||
|
health["database"] = gin.H{"status": "healthy"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Technitium connectivity
|
||||||
|
if err := h.testTechnitiumConnection(); err != nil {
|
||||||
|
health["technitium"] = gin.H{"status": "unhealthy", "error": err.Error()}
|
||||||
|
health["status"] = "degraded"
|
||||||
|
} else {
|
||||||
|
health["technitium"] = gin.H{"status": "healthy"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config (without secrets)
|
||||||
|
health["config"] = gin.H{
|
||||||
|
"base_domain": h.config.BaseDomain,
|
||||||
|
"space_subdomain": h.config.SpaceSubdomain,
|
||||||
|
"zone": h.config.GetZone(),
|
||||||
|
"technitium_url": h.config.TechnitiumURL,
|
||||||
|
"rate_limit_ip": h.config.RateLimitPerIP,
|
||||||
|
"rate_limit_token": h.config.RateLimitPerToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, health)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDNS attempts to create and delete a test DNS record
|
||||||
|
func (h *DebugHandler) TestDNS(c *gin.Context) {
|
||||||
|
testSubdomain := "test-health-check-" + fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
testIP := "1.2.3.4"
|
||||||
|
zone := h.config.GetZone()
|
||||||
|
|
||||||
|
results := gin.H{
|
||||||
|
"zone": zone,
|
||||||
|
"subdomain": testSubdomain,
|
||||||
|
"test_ip": testIP,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to add a record
|
||||||
|
err := h.dns.AddARecord(zone, testSubdomain, testIP, 60)
|
||||||
|
if err != nil {
|
||||||
|
results["add_record"] = gin.H{"success": false, "error": err.Error()}
|
||||||
|
c.JSON(http.StatusServiceUnavailable, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results["add_record"] = gin.H{"success": true}
|
||||||
|
|
||||||
|
// Try to add wildcard
|
||||||
|
err = h.dns.AddWildcardARecord(zone, testSubdomain, testIP, 60)
|
||||||
|
if err != nil {
|
||||||
|
results["add_wildcard"] = gin.H{"success": false, "error": err.Error()}
|
||||||
|
c.JSON(http.StatusServiceUnavailable, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results["add_wildcard"] = gin.H{"success": true}
|
||||||
|
|
||||||
|
// Cleanup - delete the test records
|
||||||
|
err = h.dns.DeleteRecord(zone, testSubdomain, "A")
|
||||||
|
if err != nil {
|
||||||
|
results["cleanup"] = gin.H{"warning": "Failed to cleanup test record", "error": err.Error()}
|
||||||
|
} else {
|
||||||
|
results["cleanup"] = gin.H{"success": true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete wildcard
|
||||||
|
err = h.dns.DeleteRecord(zone, "*."+testSubdomain, "A")
|
||||||
|
if err != nil {
|
||||||
|
results["cleanup_wildcard"] = gin.H{"warning": "Failed to cleanup wildcard", "error": err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
results["overall"] = "success"
|
||||||
|
c.JSON(http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDebug shows current configuration (sanitized)
|
||||||
|
func (h *DebugHandler) ConfigDebug(c *gin.Context) {
|
||||||
|
cfg := gin.H{
|
||||||
|
"server_port": h.config.ServerPort,
|
||||||
|
"database_path": h.config.DatabasePath,
|
||||||
|
"technitium_url": h.config.TechnitiumURL,
|
||||||
|
"base_domain": h.config.BaseDomain,
|
||||||
|
"space_subdomain": h.config.SpaceSubdomain,
|
||||||
|
"zone": h.config.GetZone(),
|
||||||
|
"rate_limit_per_ip": h.config.RateLimitPerIP,
|
||||||
|
"rate_limit_per_token": h.config.RateLimitPerToken,
|
||||||
|
"trusted_proxies": h.config.TrustedProxies,
|
||||||
|
// Don't expose credentials!
|
||||||
|
"has_token": h.config.TechnitiumToken != "",
|
||||||
|
"has_username": h.config.TechnitiumUsername != "",
|
||||||
|
"has_password": h.config.TechnitiumPassword != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns database statistics
|
||||||
|
func (h *DebugHandler) Stats(c *gin.Context) {
|
||||||
|
stats, err := h.db.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebugHandler) testTechnitiumConnection() error {
|
||||||
|
// Try a simple operation - add and immediately delete a test record
|
||||||
|
testSubdomain := "conn-test" + fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
err := h.dns.AddARecord(h.config.GetZone(), testSubdomain, "127.0.0.1", 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Cleanup
|
||||||
|
h.dns.DeleteRecord(h.config.GetZone(), testSubdomain, "A")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVersion() string {
|
||||||
|
if v := os.Getenv("VERSION"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,9 +13,15 @@ import (
|
|||||||
"git.dws.rip/DWS/dyn/internal/database"
|
"git.dws.rip/DWS/dyn/internal/database"
|
||||||
"git.dws.rip/DWS/dyn/internal/dns"
|
"git.dws.rip/DWS/dyn/internal/dns"
|
||||||
"git.dws.rip/DWS/dyn/internal/models"
|
"git.dws.rip/DWS/dyn/internal/models"
|
||||||
|
"github.com/TwiN/go-away"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
profanityDetector = goaway.NewProfanityDetector()
|
||||||
|
customFilter = NewCustomFilter()
|
||||||
|
)
|
||||||
|
|
||||||
type WebHandler struct {
|
type WebHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
config *config.Config
|
config *config.Config
|
||||||
@@ -49,6 +56,16 @@ func (h *WebHandler) ClaimSpace(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if profanityDetector.IsProfane(subdomain) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Subdomain contains inappropriate content"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if customFilter.IsBlocked(subdomain) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Subdomain is reserved"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -86,6 +103,24 @@ func (h *WebHandler) CheckSubdomain(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if profanityDetector.IsProfane(subdomain) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"available": false,
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"reason": "inappropriate",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if customFilter.IsBlocked(subdomain) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"available": false,
|
||||||
|
"subdomain": subdomain,
|
||||||
|
"reason": "reserved",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -136,14 +171,19 @@ func NewDynDNSHandler(db *database.DB, dnsClient *dns.Client, cfg *config.Config
|
|||||||
func (h *DynDNSHandler) Update(c *gin.Context) {
|
func (h *DynDNSHandler) Update(c *gin.Context) {
|
||||||
token, err := extractBasicAuthPassword(c)
|
token, err := extractBasicAuthPassword(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DynDNS] Auth extraction error: %v", err)
|
||||||
c.String(http.StatusUnauthorized, "badauth")
|
c.String(http.StatusUnauthorized, "badauth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname := c.Query("hostname")
|
hostname := c.Query("hostname")
|
||||||
myip := c.Query("myip")
|
myip := c.Query("myip")
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
|
||||||
|
log.Printf("[DynDNS] Update request from %s: hostname=%s, myip=%s", clientIP, hostname, myip)
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
|
log.Printf("[DynDNS] Error: hostname missing")
|
||||||
c.String(http.StatusBadRequest, "nohost")
|
c.String(http.StatusBadRequest, "nohost")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,54 +193,67 @@ func (h *DynDNSHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
space, err := h.db.GetSpaceByToken(ctx, token)
|
space, err := h.db.GetSpaceByToken(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DynDNS] Database error getting space: %v", err)
|
||||||
c.String(http.StatusServiceUnavailable, "911")
|
c.String(http.StatusServiceUnavailable, "911")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if space == nil {
|
if space == nil {
|
||||||
|
log.Printf("[DynDNS] Invalid token provided")
|
||||||
c.String(http.StatusUnauthorized, "badauth")
|
c.String(http.StatusUnauthorized, "badauth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFQDN := space.GetFQDN(h.config.GetZone())
|
expectedFQDN := space.GetFQDN(h.config.GetZone())
|
||||||
if hostname != expectedFQDN {
|
if hostname != expectedFQDN {
|
||||||
|
log.Printf("[DynDNS] Hostname mismatch: expected %s, got %s", expectedFQDN, hostname)
|
||||||
c.String(http.StatusBadRequest, "nohost")
|
c.String(http.StatusBadRequest, "nohost")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if myip == "" {
|
if myip == "" {
|
||||||
myip = c.ClientIP()
|
myip = c.ClientIP()
|
||||||
|
log.Printf("[DynDNS] Using client IP: %s", myip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if net.ParseIP(myip) == nil {
|
if net.ParseIP(myip) == nil {
|
||||||
|
log.Printf("[DynDNS] Invalid IP format: %s", myip)
|
||||||
c.String(http.StatusBadRequest, "dnserr")
|
c.String(http.StatusBadRequest, "dnserr")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
zone := h.config.GetZone()
|
zone := h.config.GetZone()
|
||||||
|
log.Printf("[DynDNS] Updating DNS: zone=%s, subdomain=%s, ip=%s", zone, space.Subdomain, myip)
|
||||||
|
|
||||||
err = h.dns.AddARecord(zone, space.Subdomain, myip, 300)
|
err = h.dns.AddARecord(zone, space.Subdomain, myip, 300)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DynDNS] Failed to add A record: %v", err)
|
||||||
c.String(http.StatusServiceUnavailable, "911")
|
c.String(http.StatusServiceUnavailable, "911")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[DynDNS] A record added successfully")
|
||||||
|
|
||||||
err = h.dns.AddWildcardARecord(zone, space.Subdomain, myip, 300)
|
err = h.dns.AddWildcardARecord(zone, space.Subdomain, myip, 300)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DynDNS] Failed to add wildcard A record: %v", err)
|
||||||
c.String(http.StatusServiceUnavailable, "911")
|
c.String(http.StatusServiceUnavailable, "911")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[DynDNS] Wildcard A record added successfully")
|
||||||
|
|
||||||
if space.LastIP == myip {
|
if space.LastIP == myip {
|
||||||
|
log.Printf("[DynDNS] IP unchanged: %s", myip)
|
||||||
c.String(http.StatusOK, "nochg %s", myip)
|
c.String(http.StatusOK, "nochg %s", myip)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.db.UpdateSpaceIP(ctx, token, myip)
|
err = h.db.UpdateSpaceIP(ctx, token, myip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DynDNS] Failed to update space IP in database: %v", err)
|
||||||
c.String(http.StatusServiceUnavailable, "911")
|
c.String(http.StatusServiceUnavailable, "911")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[DynDNS] Update successful: %s -> %s", hostname, myip)
|
||||||
c.String(http.StatusOK, "good %s", myip)
|
c.String(http.StatusOK, "good %s", myip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
243
internal/handlers/handlers_test.go
Normal file
243
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomFilter_IsBlocked(t *testing.T) {
|
||||||
|
cf := NewCustomFilter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match - dws",
|
||||||
|
subdomain: "dws",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - dubey",
|
||||||
|
subdomain: "dubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - tanishq",
|
||||||
|
subdomain: "tanishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - tdubey",
|
||||||
|
subdomain: "tdubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with hyphens - dubey-web",
|
||||||
|
subdomain: "dubey-web",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with hyphens - tanishq-dubey",
|
||||||
|
subdomain: "tanishq-dubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - dub3y",
|
||||||
|
subdomain: "dub3y",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - t4nishq",
|
||||||
|
subdomain: "t4nishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - dw5",
|
||||||
|
subdomain: "dw5",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combined term - dubeydns",
|
||||||
|
subdomain: "dubeydns",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combined term - dws-ddns",
|
||||||
|
subdomain: "dws-ddns",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - myhome",
|
||||||
|
subdomain: "myhome",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - office",
|
||||||
|
subdomain: "office",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - server01",
|
||||||
|
subdomain: "server01",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive - DWS",
|
||||||
|
subdomain: "DWS",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive - Tanishq",
|
||||||
|
subdomain: "Tanishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case - DuBeY",
|
||||||
|
subdomain: "DuBeY",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cf.IsBlocked(tt.subdomain)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsBlocked(%q) = %v, want %v", tt.subdomain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomFilter_normalize(t *testing.T) {
|
||||||
|
cf := NewCustomFilter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "lowercase conversion",
|
||||||
|
text: "DuBeY",
|
||||||
|
want: "dubey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove hyphens",
|
||||||
|
text: "dubey-web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove underscores",
|
||||||
|
text: "dubey_web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove dots",
|
||||||
|
text: "dubey.web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove spaces",
|
||||||
|
text: "dubey web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion",
|
||||||
|
text: "dub3y",
|
||||||
|
want: "dubey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion - t4nishq",
|
||||||
|
text: "t4nishq",
|
||||||
|
want: "tanishq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion - dw5",
|
||||||
|
text: "dw5",
|
||||||
|
want: "dws",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cf.normalize(tt.text)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalize(%q) = %q, want %q", tt.text, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSubdomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid - simple",
|
||||||
|
subdomain: "myhome",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - with hyphen",
|
||||||
|
subdomain: "my-home",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - with numbers",
|
||||||
|
subdomain: "home123",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - minimum length",
|
||||||
|
subdomain: "abc",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - too short",
|
||||||
|
subdomain: "ab",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - too long",
|
||||||
|
subdomain: "thisisaverylongsubdomainthatexceedsthesixtythreecharacterlimitforsubdomains",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - starts with hyphen",
|
||||||
|
subdomain: "-myhome",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - ends with hyphen",
|
||||||
|
subdomain: "myhome-",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - contains special chars",
|
||||||
|
subdomain: "my_home",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - contains dot",
|
||||||
|
subdomain: "my.home",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - empty",
|
||||||
|
subdomain: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := isValidSubdomain(tt.subdomain)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isValidSubdomain(%q) = %v, want %v", tt.subdomain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/handlers/validation.go
Normal file
45
internal/handlers/validation.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register custom validators
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
v.RegisterValidation("alphanumdash", alphanumdashValidator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphanumdashValidator validates that a string contains only alphanumeric characters and hyphens
|
||||||
|
var alphanumdashValidator validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
value := fl.Field().String()
|
||||||
|
// Allow alphanumeric and hyphens only
|
||||||
|
match := regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidSubdomainFormat checks if a subdomain string is valid (exported for use in validation)
|
||||||
|
func IsValidSubdomainFormat(subdomain string) bool {
|
||||||
|
// Must be at least 3 characters
|
||||||
|
if len(subdomain) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not exceed 63 characters
|
||||||
|
if len(subdomain) > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not start or end with hyphen
|
||||||
|
if strings.HasPrefix(subdomain, "-") || strings.HasSuffix(subdomain, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must contain only alphanumeric and hyphens
|
||||||
|
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(subdomain)
|
||||||
|
}
|
||||||
238
internal/testutil/mock_technitium.go
Normal file
238
internal/testutil/mock_technitium.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockTechnitiumServer simulates the Technitium DNS API for testing
|
||||||
|
type MockTechnitiumServer struct {
|
||||||
|
Server *httptest.Server
|
||||||
|
Records map[string]MockDNSRecord
|
||||||
|
mu sync.RWMutex
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockDNSRecord represents a DNS record stored in the mock server
|
||||||
|
type MockDNSRecord struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockTechnitiumServer creates a new mock Technitium server
|
||||||
|
func NewMockTechnitiumServer() *MockTechnitiumServer {
|
||||||
|
mock := &MockTechnitiumServer{
|
||||||
|
Records: make(map[string]MockDNSRecord),
|
||||||
|
Username: "admin",
|
||||||
|
Password: "test-password",
|
||||||
|
Token: "test-api-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/dns/records/add", mock.handleAddRecord)
|
||||||
|
mux.HandleFunc("/api/dns/records/delete", mock.handleDeleteRecord)
|
||||||
|
mux.HandleFunc("/api/dns/records/get", mock.handleGetRecords)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("Technitium DNS Server Mock"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.Server = httptest.NewServer(mux)
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the mock server
|
||||||
|
func (m *MockTechnitiumServer) Close() {
|
||||||
|
m.Server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL returns the base URL of the mock server
|
||||||
|
func (m *MockTechnitiumServer) URL() string {
|
||||||
|
return m.Server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecords returns all stored DNS records (for testing assertions)
|
||||||
|
func (m *MockTechnitiumServer) GetRecords() map[string]MockDNSRecord {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return a copy to avoid race conditions
|
||||||
|
records := make(map[string]MockDNSRecord)
|
||||||
|
for k, v := range m.Records {
|
||||||
|
records[k] = v
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecordCount returns the number of stored records
|
||||||
|
func (m *MockTechnitiumServer) GetRecordCount() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.Records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRecords removes all stored records
|
||||||
|
func (m *MockTechnitiumServer) ClearRecords() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.Records = make(map[string]MockDNSRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) authenticate(r *http.Request) bool {
|
||||||
|
// Check for API token in header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "Basic "+m.Token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for username/password in basic auth
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if ok && user == m.Username && pass == m.Password {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleAddRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.FormValue("domain")
|
||||||
|
recordType := r.FormValue("type")
|
||||||
|
ipAddress := r.FormValue("ipAddress")
|
||||||
|
|
||||||
|
if domain == "" || recordType == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the record
|
||||||
|
m.mu.Lock()
|
||||||
|
key := fmt.Sprintf("%s:%s", domain, recordType)
|
||||||
|
m.Records[key] = MockDNSRecord{
|
||||||
|
Domain: domain,
|
||||||
|
Type: recordType,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
TTL: 300,
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"domain": domain,
|
||||||
|
"type": recordType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleDeleteRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.FormValue("domain")
|
||||||
|
recordType := r.FormValue("type")
|
||||||
|
|
||||||
|
// Delete the record
|
||||||
|
m.mu.Lock()
|
||||||
|
key := fmt.Sprintf("%s:%s", domain, recordType)
|
||||||
|
delete(m.Records, key)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleGetRecords(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.URL.Query().Get("domain")
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
var records []MockDNSRecord
|
||||||
|
for _, record := range m.Records {
|
||||||
|
if domain == "" || record.Domain == domain {
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"domain": domain,
|
||||||
|
"records": records,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
365
k8s/README.md
Normal file
365
k8s/README.md
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
# Kubernetes Deployment
|
||||||
|
|
||||||
|
This directory contains Kubernetes manifests for deploying the DWS Dynamic DNS service on K3s, Kubernetes, or any K8s-compatible platform.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
k8s/
|
||||||
|
├── base/ # Base manifests (don't edit directly)
|
||||||
|
│ ├── deployment.yaml # Deployment, Service, PVC
|
||||||
|
│ ├── configmap.yaml # Non-sensitive configuration
|
||||||
|
│ ├── secrets.yaml # Sensitive configuration (placeholders)
|
||||||
|
│ ├── ingress.yaml # Ingress with TLS
|
||||||
|
│ └── kustomization.yaml # Base kustomization
|
||||||
|
│
|
||||||
|
├── overlays/
|
||||||
|
│ ├── production/ # Production environment
|
||||||
|
│ │ ├── kustomization.yaml # Production-specific settings
|
||||||
|
│ │ ├── deployment-patch.yaml # Resource adjustments
|
||||||
|
│ │ ├── namespace.yaml # Production namespace
|
||||||
|
│ │ ├── secrets.yaml # Production secrets (gitignored)
|
||||||
|
│ │ └── secrets.example.yaml # Example secrets template
|
||||||
|
│ │
|
||||||
|
│ └── staging/ # Staging environment
|
||||||
|
│ ├── kustomization.yaml # Staging-specific settings
|
||||||
|
│ ├── deployment-patch.yaml # Single replica, lower resources
|
||||||
|
│ ├── namespace.yaml # Staging namespace
|
||||||
|
│ ├── secrets.yaml # Staging secrets (gitignored)
|
||||||
|
│ └── secrets.example.yaml # Example secrets template
|
||||||
|
│
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes 1.21+ or K3s cluster
|
||||||
|
- kubectl configured with cluster access
|
||||||
|
- cert-manager installed (for TLS certificates)
|
||||||
|
- Ingress controller (Traefik, NGINX, etc.)
|
||||||
|
- Storage class for persistent volumes
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and enter directory
|
||||||
|
git clone https://git.dws.rip/DWS/dyn.git
|
||||||
|
cd dyn/k8s
|
||||||
|
|
||||||
|
# 2. Create production secrets
|
||||||
|
cp overlays/production/secrets.example.yaml overlays/production/secrets.yaml
|
||||||
|
|
||||||
|
# 3. Edit secrets with your Technitium credentials
|
||||||
|
# Replace 'your-production-api-token-here' with actual token
|
||||||
|
nano overlays/production/secrets.yaml
|
||||||
|
|
||||||
|
# 4. Deploy to production
|
||||||
|
kubectl apply -k overlays/production
|
||||||
|
|
||||||
|
# 5. Verify deployment
|
||||||
|
kubectl get pods -n dyn-ddns
|
||||||
|
kubectl get svc -n dyn-ddns
|
||||||
|
kubectl get ingress -n dyn-ddns
|
||||||
|
|
||||||
|
# 6. Check logs
|
||||||
|
kubectl logs -n dyn-ddns -l app.kubernetes.io/name=dyn-ddns -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Similar process for staging
|
||||||
|
cp overlays/staging/secrets.example.yaml overlays/staging/secrets.yaml
|
||||||
|
# Edit with staging credentials
|
||||||
|
kubectl apply -k overlays/staging
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Secrets (Required)
|
||||||
|
|
||||||
|
Create `overlays/production/secrets.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Choose ONE authentication method:
|
||||||
|
|
||||||
|
# Method 1: API Token (recommended)
|
||||||
|
TECHNITIUM_TOKEN: "your-actual-api-token-here"
|
||||||
|
|
||||||
|
# Method 2: Username/Password
|
||||||
|
# TECHNITIUM_USERNAME: "admin"
|
||||||
|
# TECHNITIUM_PASSWORD: "your-password"
|
||||||
|
|
||||||
|
# Optional: Trusted proxies
|
||||||
|
# TRUSTED_PROXIES: "10.0.0.0/8,172.16.0.0/12"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Never commit secrets.yaml to git. It's already in .gitignore.
|
||||||
|
|
||||||
|
### ConfigMap (Optional Overrides)
|
||||||
|
|
||||||
|
Edit `overlays/production/kustomization.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configMapGenerator:
|
||||||
|
- name: dyn-ddns-config
|
||||||
|
behavior: merge
|
||||||
|
literals:
|
||||||
|
- TECHNITIUM_URL=https://dns.dws.rip
|
||||||
|
- BASE_DOMAIN=dws.rip
|
||||||
|
- SPACE_SUBDOMAIN=space
|
||||||
|
- RATE_LIMIT_PER_IP=10
|
||||||
|
- RATE_LIMIT_PER_TOKEN=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress Customization
|
||||||
|
|
||||||
|
By default, the ingress is configured for **Traefik** with cert-manager:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
```
|
||||||
|
|
||||||
|
For **NGINX** ingress, change annotations in `base/ingress.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **Deployment** - Runs the DDNS bridge container
|
||||||
|
- Replicas: 2 (production), 1 (staging)
|
||||||
|
- Resource limits configurable per overlay
|
||||||
|
- Health checks (liveness & readiness probes)
|
||||||
|
|
||||||
|
2. **Service** - ClusterIP exposing port 80
|
||||||
|
|
||||||
|
3. **Ingress** - TLS termination at edge
|
||||||
|
- Host: dyn.dws.rip (customize as needed)
|
||||||
|
- Automatic certificate via cert-manager
|
||||||
|
|
||||||
|
4. **PersistentVolumeClaim** - SQLite database storage
|
||||||
|
- Size: 1Gi (adjustable)
|
||||||
|
- AccessMode: ReadWriteOnce
|
||||||
|
|
||||||
|
### Resource Requirements
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- CPU: 200m request / 1000m limit
|
||||||
|
- Memory: 128Mi request / 512Mi limit
|
||||||
|
- Replicas: 2
|
||||||
|
|
||||||
|
**Staging:**
|
||||||
|
- CPU: 100m request / 500m limit
|
||||||
|
- Memory: 64Mi request / 256Mi limit
|
||||||
|
- Replicas: 1
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All pods
|
||||||
|
kubectl logs -n dyn-ddns -l app.kubernetes.io/name=dyn-ddns
|
||||||
|
|
||||||
|
# Specific pod
|
||||||
|
kubectl logs -n dyn-ddns -f deployment/prod-dyn-ddns
|
||||||
|
|
||||||
|
# Previous container logs (after restart)
|
||||||
|
kubectl logs -n dyn-ddns --previous deployment/prod-dyn-ddns
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scale Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale to 3 replicas
|
||||||
|
kubectl scale deployment -n dyn-ddns prod-dyn-ddns --replicas=3
|
||||||
|
|
||||||
|
# Edit deployment directly
|
||||||
|
kubectl edit deployment -n dyn-ddns prod-dyn-ddns
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Backup
|
||||||
|
|
||||||
|
The SQLite database is stored in the persistent volume at `/data/dyn.db`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the pod
|
||||||
|
POD=$(kubectl get pod -n dyn-ddns -l app.kubernetes.io/name=dyn-ddns -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
|
||||||
|
# Copy database locally
|
||||||
|
kubectl cp dyn-ddns/$POD:/data/dyn.db ./dyn-backup-$(date +%Y%m%d).db
|
||||||
|
|
||||||
|
# Or exec into pod
|
||||||
|
kubectl exec -it -n dyn-ddns $POD -- sh
|
||||||
|
# Then: sqlite3 /data/dyn.db "SELECT * FROM spaces;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update to latest image
|
||||||
|
kubectl rollout restart deployment -n dyn-ddns prod-dyn-ddns
|
||||||
|
|
||||||
|
# Or set specific image tag
|
||||||
|
kubectl set image deployment -n dyn-ddns prod-dyn-ddns dyn-ddns=git.dws.rip/DWS/dyn:v1.0.0
|
||||||
|
|
||||||
|
# Monitor rollout
|
||||||
|
kubectl rollout status deployment -n dyn-ddns prod-dyn-ddns
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**Pod stuck in Pending:**
|
||||||
|
```bash
|
||||||
|
kubectl describe pod -n dyn-ddns <pod-name>
|
||||||
|
# Check: Storage class available? PV provisioned?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pod crash looping:**
|
||||||
|
```bash
|
||||||
|
kubectl logs -n dyn-ddns --previous <pod-name>
|
||||||
|
# Check: Secrets configured? Technitium URL reachable?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ingress not working:**
|
||||||
|
```bash
|
||||||
|
kubectl describe ingress -n dyn-ddns prod-dyn-ddns
|
||||||
|
kubectl get certificate -n dyn-ddns
|
||||||
|
# Check: DNS pointing to ingress controller? Cert-manager working?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Multi-Environment Setup
|
||||||
|
|
||||||
|
Deploy to multiple environments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Staging
|
||||||
|
kubectl apply -k overlays/staging
|
||||||
|
|
||||||
|
# Production
|
||||||
|
kubectl apply -k overlays/production
|
||||||
|
|
||||||
|
# Verify both
|
||||||
|
kubectl get pods --all-namespaces -l app.kubernetes.io/name=dyn-ddns
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Overlay
|
||||||
|
|
||||||
|
Create your own overlay for specific needs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir overlays/custom
|
||||||
|
cat > overlays/custom/kustomization.yaml << 'EOF'
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
|
||||||
|
namespace: my-namespace
|
||||||
|
|
||||||
|
configMapGenerator:
|
||||||
|
- name: dyn-ddns-config
|
||||||
|
behavior: merge
|
||||||
|
literals:
|
||||||
|
- TECHNITIUM_URL=https://my-dns-server.example.com
|
||||||
|
- RATE_LIMIT_PER_IP=5
|
||||||
|
EOF
|
||||||
|
|
||||||
|
kubectl apply -k overlays/custom
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
The deployment exposes standard metrics. Add Prometheus scraping:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Add to deployment-patch.yaml
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/port: "8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Secrets Management**
|
||||||
|
- Never commit secrets to git
|
||||||
|
- Consider using external secrets operator (Vault, Sealed Secrets)
|
||||||
|
- Rotate Technitium API tokens regularly
|
||||||
|
|
||||||
|
2. **Network Policies**
|
||||||
|
```yaml
|
||||||
|
# Example network policy
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-netpol
|
||||||
|
namespace: dyn-ddns
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: dyn-ddns
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
- Egress
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
name: ingress-nginx
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8080
|
||||||
|
egress:
|
||||||
|
- to: [] # Allow all egress (for Technitium API)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Pod Security**
|
||||||
|
- Container runs as non-root (in Dockerfile)
|
||||||
|
- Read-only root filesystem recommended
|
||||||
|
- Drop all capabilities
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Regular Tasks
|
||||||
|
|
||||||
|
1. **Backup database weekly**
|
||||||
|
2. **Monitor rate limit metrics**
|
||||||
|
3. **Review access logs**
|
||||||
|
4. **Update base image for security patches**
|
||||||
|
|
||||||
|
### Version Upgrades
|
||||||
|
|
||||||
|
1. Update image tag in overlay kustomization
|
||||||
|
2. Apply changes: `kubectl apply -k overlays/production`
|
||||||
|
3. Verify rollout: `kubectl rollout status ...`
|
||||||
|
4. Monitor for errors in logs
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues specific to Kubernetes deployment:
|
||||||
|
1. Check pod logs: `kubectl logs ...`
|
||||||
|
2. Describe resources: `kubectl describe ...`
|
||||||
|
3. Check ingress controller logs
|
||||||
|
4. Verify cert-manager is issuing certificates
|
||||||
13
k8s/base/configmap.yaml
Normal file
13
k8s/base/configmap.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-config
|
||||||
|
data:
|
||||||
|
SERVER_PORT: "8080"
|
||||||
|
DATABASE_PATH: "/data/dyn.db"
|
||||||
|
BASE_DOMAIN: "dws.rip"
|
||||||
|
SPACE_SUBDOMAIN: "space"
|
||||||
|
RATE_LIMIT_PER_IP: "10"
|
||||||
|
RATE_LIMIT_PER_TOKEN: "1"
|
||||||
|
# TECHNITIUM_URL: "https://dns.dws.rip"
|
||||||
|
# Add your Technitium URL above or in overlays
|
||||||
86
k8s/base/deployment.yaml
Normal file
86
k8s/base/deployment.yaml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
labels:
|
||||||
|
app: dyn-ddns
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: dyn-ddns
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: dyn-ddns
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dyn-ddns
|
||||||
|
image: git.dws.rip/DWS/dyn:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: http
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: dyn-ddns-config
|
||||||
|
- secretRef:
|
||||||
|
name: dyn-ddns-secrets
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8080
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dyn-ddns-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
labels:
|
||||||
|
app: dyn-ddns
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: dyn-ddns
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-data
|
||||||
|
labels:
|
||||||
|
app: dyn-ddns
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
30
k8s/base/ingress.yaml
Normal file
30
k8s/base/ingress.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
annotations:
|
||||||
|
# Traefik
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
|
||||||
|
# NGINX (uncomment if using NGINX ingress)
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- dyn.dws.rip
|
||||||
|
secretName: dyn-ddns-tls
|
||||||
|
rules:
|
||||||
|
- host: dyn.dws.rip
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: dyn-ddns
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
17
k8s/base/kustomization.yaml
Normal file
17
k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- ingress.yaml
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
app.kubernetes.io/name: dyn-ddns
|
||||||
|
app.kubernetes.io/part-of: dws-dns
|
||||||
|
|
||||||
|
namespace: default
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: git.dws.rip/DWS/dyn
|
||||||
|
newTag: latest
|
||||||
17
k8s/base/secrets.yaml
Normal file
17
k8s/base/secrets.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Choose ONE authentication method:
|
||||||
|
|
||||||
|
# Method 1: API Token (recommended)
|
||||||
|
# TECHNITIUM_TOKEN: "your-api-token-here"
|
||||||
|
|
||||||
|
# Method 2: Username/Password
|
||||||
|
# TECHNITIUM_USERNAME: "admin"
|
||||||
|
# TECHNITIUM_PASSWORD: "your-password"
|
||||||
|
|
||||||
|
# Optional: Trusted proxies (comma-separated)
|
||||||
|
# TRUSTED_PROXIES: "10.0.0.0/8,172.16.0.0/12"
|
||||||
16
k8s/overlays/production/deployment-patch.yaml
Normal file
16
k8s/overlays/production/deployment-patch.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dyn-ddns
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "200m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "1000m"
|
||||||
28
k8s/overlays/production/kustomization.yaml
Normal file
28
k8s/overlays/production/kustomization.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- ../../base
|
||||||
|
- secrets.yaml
|
||||||
|
|
||||||
|
namePrefix: prod-
|
||||||
|
namespace: dyn-ddns
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
configMapGenerator:
|
||||||
|
- name: dyn-ddns-config
|
||||||
|
behavior: merge
|
||||||
|
literals:
|
||||||
|
- TECHNITIUM_URL=https://dns.dws.rip
|
||||||
|
- RATE_LIMIT_PER_IP=10
|
||||||
|
- RATE_LIMIT_PER_TOKEN=1
|
||||||
|
|
||||||
|
patchesStrategicMerge:
|
||||||
|
- deployment-patch.yaml
|
||||||
|
|
||||||
|
replicas:
|
||||||
|
- name: dyn-ddns
|
||||||
|
count: 2
|
||||||
7
k8s/overlays/production/namespace.yaml
Normal file
7
k8s/overlays/production/namespace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: dyn-ddns
|
||||||
|
app.kubernetes.io/part-of: dws-dns
|
||||||
12
k8s/overlays/production/secrets.example.yaml
Normal file
12
k8s/overlays/production/secrets.example.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Replace with your actual Technitium API token
|
||||||
|
TECHNITIUM_TOKEN: "your-production-api-token-here"
|
||||||
|
|
||||||
|
# Or use username/password (not recommended for production)
|
||||||
|
# TECHNITIUM_USERNAME: "admin"
|
||||||
|
# TECHNITIUM_PASSWORD: "your-password"
|
||||||
17
k8s/overlays/staging/deployment-patch.yaml
Normal file
17
k8s/overlays/staging/deployment-patch.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dyn-ddns
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "64Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
24
k8s/overlays/staging/kustomization.yaml
Normal file
24
k8s/overlays/staging/kustomization.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- ../../base
|
||||||
|
- secrets.yaml
|
||||||
|
|
||||||
|
namePrefix: staging-
|
||||||
|
namespace: dyn-ddns-staging
|
||||||
|
|
||||||
|
commonLabels:
|
||||||
|
environment: staging
|
||||||
|
|
||||||
|
configMapGenerator:
|
||||||
|
- name: dyn-ddns-config
|
||||||
|
behavior: merge
|
||||||
|
literals:
|
||||||
|
- TECHNITIUM_URL=https://dns-staging.dws.rip
|
||||||
|
- RATE_LIMIT_PER_IP=100
|
||||||
|
- RATE_LIMIT_PER_TOKEN=10
|
||||||
|
|
||||||
|
patchesStrategicMerge:
|
||||||
|
- deployment-patch.yaml
|
||||||
7
k8s/overlays/staging/namespace.yaml
Normal file
7
k8s/overlays/staging/namespace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-staging
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: dyn-ddns
|
||||||
|
app.kubernetes.io/part-of: dws-dns
|
||||||
8
k8s/overlays/staging/secrets.example.yaml
Normal file
8
k8s/overlays/staging/secrets.example.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: dyn-ddns-secrets
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# Replace with your staging Technitium credentials
|
||||||
|
TECHNITIUM_TOKEN: "your-staging-api-token-here"
|
||||||
234
tests/integration/compose_integration_test.go
Normal file
234
tests/integration/compose_integration_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComposeIntegrationTest runs integration tests against a containerized instance
|
||||||
|
func TestComposeIntegration(t *testing.T) {
|
||||||
|
if os.Getenv("RUN_COMPOSE_TESTS") != "true" {
|
||||||
|
t.Skip("Skipping compose integration tests. Set RUN_COMPOSE_TESTS=true to run")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeCmd := detectComposeCommand(t)
|
||||||
|
projectName := "dyn-test-" + fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
cleanup := func() {
|
||||||
|
t.Log("Cleaning up containers...")
|
||||||
|
cmd := exec.Command(composeCmd, "-f", "../../docker-compose.yml", "-p", projectName, "down", "-v")
|
||||||
|
cmd.Run()
|
||||||
|
os.Remove(".env.test")
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create test environment
|
||||||
|
envContent := `SERVER_PORT=8080
|
||||||
|
DATABASE_PATH=/data/dyn.db
|
||||||
|
TECHNITIUM_URL=http://mock-dns:8080
|
||||||
|
TECHNITIUM_TOKEN=test-token
|
||||||
|
BASE_DOMAIN=test.rip
|
||||||
|
SPACE_SUBDOMAIN=space
|
||||||
|
RATE_LIMIT_PER_IP=100
|
||||||
|
RATE_LIMIT_PER_TOKEN=100
|
||||||
|
`
|
||||||
|
err := os.WriteFile(".env.test", []byte(envContent), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Start services
|
||||||
|
t.Log("Starting services with", composeCmd)
|
||||||
|
cmd := exec.Command(composeCmd, "-f", "../../docker-compose.yml", "-p", projectName, "--env-file", ".env.test", "up", "-d", "--build")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to start services: %v\nOutput: %s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for service to be ready
|
||||||
|
baseURL := waitForService(t, "http://localhost:8080", 60)
|
||||||
|
t.Logf("Service ready at %s", baseURL)
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
t.Run("HealthCheck", func(t *testing.T) {
|
||||||
|
testHealthCheck(t, baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CheckSubdomain", func(t *testing.T) {
|
||||||
|
testCheckSubdomain(t, baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ClaimSpace", func(t *testing.T) {
|
||||||
|
testClaimSpace(t, baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ProfanityFilter", func(t *testing.T) {
|
||||||
|
testProfanityFilterCompose(t, baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CustomFilter", func(t *testing.T) {
|
||||||
|
testCustomFilterCompose(t, baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DynDNSEndpoint", func(t *testing.T) {
|
||||||
|
testDynDNSEndpoint(t, baseURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectComposeCommand(t *testing.T) string {
|
||||||
|
// Try podman-compose first
|
||||||
|
if _, err := exec.LookPath("podman-compose"); err == nil {
|
||||||
|
t.Log("Using podman-compose")
|
||||||
|
return "podman-compose"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try docker-compose
|
||||||
|
if _, err := exec.LookPath("docker-compose"); err == nil {
|
||||||
|
t.Log("Using docker-compose")
|
||||||
|
return "docker-compose"
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Skip("Neither podman-compose nor docker-compose found")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForService(t *testing.T, url string, timeout int) string {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
|
||||||
|
for i := 0; i < timeout; i++ {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("Service did not become ready within %d seconds", timeout)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHealthCheck(t *testing.T, baseURL string) {
|
||||||
|
resp, err := http.Get(baseURL + "/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
body.ReadFrom(resp.Body)
|
||||||
|
assert.Contains(t, body.String(), "DWS Dynamic DNS")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheckSubdomain(t *testing.T, baseURL string) {
|
||||||
|
// Test available subdomain
|
||||||
|
resp, err := http.Get(baseURL + "/api/check?subdomain=newspace")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result["available"].(bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClaimSpace(t *testing.T, baseURL string) string {
|
||||||
|
payload := map[string]string{"subdomain": "testclaim"}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
resp, err := http.Post(baseURL+"/api/claim", "application/json", bytes.NewBuffer(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
token := result["token"].(string)
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
assert.Equal(t, "testclaim", result["subdomain"])
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProfanityFilterCompose(t *testing.T, baseURL string) {
|
||||||
|
profaneWords := []string{"fuck", "shit", "bitch"}
|
||||||
|
|
||||||
|
for _, word := range profaneWords {
|
||||||
|
t.Run(word, func(t *testing.T) {
|
||||||
|
payload := map[string]string{"subdomain": word}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
resp, err := http.Post(baseURL+"/api/claim", "application/json", bytes.NewBuffer(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]string
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result["error"], "inappropriate")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCustomFilterCompose(t *testing.T, baseURL string) {
|
||||||
|
reservedWords := []string{"dws", "dubey", "tanishq"}
|
||||||
|
|
||||||
|
for _, word := range reservedWords {
|
||||||
|
t.Run(word, func(t *testing.T) {
|
||||||
|
payload := map[string]string{"subdomain": word}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
resp, err := http.Post(baseURL+"/api/claim", "application/json", bytes.NewBuffer(body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
|
||||||
|
var result map[string]string
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
assert.Contains(t, result["error"], "reserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDynDNSEndpoint(t *testing.T, baseURL string) {
|
||||||
|
// First claim a space
|
||||||
|
token := testClaimSpace(t, baseURL)
|
||||||
|
|
||||||
|
// Try DynDNS update
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", baseURL+"/api/nic/update?hostname=testclaim.space.test.rip&myip=192.168.1.100", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Basic "+basicAuth("none", token))
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// We expect 200 or 503 (since there's no real DNS server)
|
||||||
|
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicAuth(username, password string) string {
|
||||||
|
auth := username + ":" + password
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||||
|
}
|
||||||
282
tests/integration/compose_test.sh
Executable file
282
tests/integration/compose_test.sh
Executable file
@@ -0,0 +1,282 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
TEST_TIMEOUT=60
|
||||||
|
CONTAINER_NAME="dyn-ddns-test"
|
||||||
|
VERBOSE=false
|
||||||
|
|
||||||
|
# Generate unique test identifiers
|
||||||
|
TEST_ID=$(date +%s%N | tail -c 8)
|
||||||
|
TEST_SUBDOMAIN="test${TEST_ID}"
|
||||||
|
CLAIM_SUBDOMAIN="claim${TEST_ID}"
|
||||||
|
PROFANE_TEST="profane${TEST_ID}"
|
||||||
|
RESERVED_TEST="reserved${TEST_ID}"
|
||||||
|
INVALID_TEST="ab${TEST_ID}"
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-v|--verbose)
|
||||||
|
VERBOSE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Usage: $0 [-v|--verbose]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verbose logging function
|
||||||
|
log_verbose() {
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
echo -e "${BLUE}[VERBOSE] $1${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_logs() {
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
echo -e "${YELLOW}=== Container Logs ===${NC}"
|
||||||
|
$COMPOSE_CMD -f $COMPOSE_FILE -p $CONTAINER_NAME logs --tail=50 || true
|
||||||
|
echo -e "${YELLOW}=== End Logs ===${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${YELLOW}=== DDNS Container Integration Test ===${NC}"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
echo -e "${BLUE}Verbose mode enabled${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect compose command
|
||||||
|
if command -v podman-compose &> /dev/null; then
|
||||||
|
COMPOSE_CMD="podman-compose"
|
||||||
|
echo -e "${GREEN}Using podman-compose${NC}"
|
||||||
|
elif command -v docker-compose &> /dev/null; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
echo -e "${GREEN}Using docker-compose${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: Neither podman-compose nor docker-compose found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
echo -e "${YELLOW}Cleaning up...${NC}"
|
||||||
|
dump_logs
|
||||||
|
$COMPOSE_CMD -f $COMPOSE_FILE -p $CONTAINER_NAME down -v 2>/dev/null || true
|
||||||
|
rm -f .env.test
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set trap for cleanup on exit
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Create test environment file
|
||||||
|
cat > .env.test << EOF
|
||||||
|
SERVER_PORT=8080
|
||||||
|
DATABASE_PATH=/data/dyn.db
|
||||||
|
TECHNITIUM_URL=http://mock-dns:8080
|
||||||
|
TECHNITIUM_TOKEN=test-token
|
||||||
|
BASE_DOMAIN=test.rip
|
||||||
|
SPACE_SUBDOMAIN=space
|
||||||
|
RATE_LIMIT_PER_IP=100
|
||||||
|
RATE_LIMIT_PER_TOKEN=100
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_verbose "Environment file created:"
|
||||||
|
log_verbose "$(cat .env.test)"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Starting services...${NC}"
|
||||||
|
$COMPOSE_CMD -f $COMPOSE_FILE -p $CONTAINER_NAME --env-file .env.test up -d --build --renew-anon-volumes 2>&1 | tee /tmp/compose-build.log
|
||||||
|
|
||||||
|
log_verbose "Build log saved to /tmp/compose-build.log"
|
||||||
|
|
||||||
|
# Wait for services to be ready
|
||||||
|
echo -e "${YELLOW}Waiting for services to start (timeout: ${TEST_TIMEOUT}s)...${NC}"
|
||||||
|
for i in $(seq 1 $TEST_TIMEOUT); do
|
||||||
|
if curl -s http://localhost:8080/ > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}Services are ready!${NC}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq $TEST_TIMEOUT ]; then
|
||||||
|
echo -e "${RED}Timeout waiting for services${NC}"
|
||||||
|
dump_logs
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Running integration tests...${NC}"
|
||||||
|
|
||||||
|
# Test 1: Health check
|
||||||
|
echo -n "Test 1: Health check... "
|
||||||
|
log_verbose "Running: curl -v http://localhost:8080/"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
HTTP_RESPONSE=$(curl -v http://localhost:8080/ 2>&1)
|
||||||
|
echo "$HTTP_RESPONSE" | tee /tmp/test1.log
|
||||||
|
else
|
||||||
|
HTTP_RESPONSE=$(curl -s http://localhost:8080/)
|
||||||
|
fi
|
||||||
|
if echo "$HTTP_RESPONSE" | grep -q "DWS Dynamic DNS"; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC}"
|
||||||
|
log_verbose "Response: $HTTP_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Check subdomain availability
|
||||||
|
echo -n "Test 2: Check subdomain availability... "
|
||||||
|
log_verbose "Running: curl -v http://localhost:8080/api/check?subdomain=testspace"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v "http://localhost:8080/api/check?subdomain=testspace" 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test2.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s "http://localhost:8080/api/check?subdomain=testspace")
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
if echo "$RESPONSE" | grep -q '"available":true'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Claim space
|
||||||
|
echo -n "Test 3: Claim space... "
|
||||||
|
log_verbose "Running: curl -v -X POST http://localhost:8080/api/claim"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"subdomain\":\"$CLAIM_SUBDOMAIN\"}" 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test3.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"subdomain\":\"$CLAIM_SUBDOMAIN\"}")
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
# Check for HTTP 201 Created status and valid token format
|
||||||
|
if echo "$RESPONSE" | grep -q 'HTTP/1.1 201 Created' && echo "$RESPONSE" | grep -q '"token":"[A-Za-z0-9_-]\+"'; then
|
||||||
|
TOKEN=$(echo "$RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
echo -e "${GREEN}PASS${NC} (token: ${TOKEN:0:20}...)"
|
||||||
|
log_verbose "Full token: $TOKEN"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: Check claimed subdomain is not available
|
||||||
|
echo -n "Test 4: Check claimed subdomain unavailable... "
|
||||||
|
log_verbose "Running: curl -v http://localhost:8080/api/check?subdomain=$CLAIM_SUBDOMAIN"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v "http://localhost:8080/api/check?subdomain=$CLAIM_SUBDOMAIN" 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test4.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s "http://localhost:8080/api/check?subdomain=$CLAIM_SUBDOMAIN")
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
if echo "$RESPONSE" | grep -q '"available":false'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: DynDNS update (mocked - will fail DNS but test auth)
|
||||||
|
echo -n "Test 5: DynDNS update endpoint... "
|
||||||
|
log_verbose "Running: curl -v -u none:<token> http://localhost:8080/api/nic/update?hostname=$CLAIM_SUBDOMAIN.space.test.rip&myip=192.168.1.100"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v -u "none:$TOKEN" \
|
||||||
|
"http://localhost:8080/api/nic/update?hostname=$CLAIM_SUBDOMAIN.space.test.rip&myip=192.168.1.100" 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test5.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s -u "none:$TOKEN" \
|
||||||
|
"http://localhost:8080/api/nic/update?hostname=$CLAIM_SUBDOMAIN.space.test.rip&myip=192.168.1.100")
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
# We expect 911 since there's no real DNS server, but auth should work
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}PASS${NC} (endpoint reachable, response: $(echo "$RESPONSE" | head -c 50))"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Profanity filter
|
||||||
|
echo -n "Test 6: Profanity filter... "
|
||||||
|
log_verbose "Running: curl -v -X POST http://localhost:8080/api/claim with profane subdomain"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"fuck"}' 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test6.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"fuck"}')
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
if echo "$RESPONSE" | grep -q 'inappropriate'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 7: Custom filter (DWS terms)
|
||||||
|
echo -n "Test 7: Custom filter (DWS terms)... "
|
||||||
|
log_verbose "Running: curl -v -X POST http://localhost:8080/api/claim with reserved subdomain"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"dws"}' 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test7.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"dws"}')
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
if echo "$RESPONSE" | grep -q 'reserved'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 8: Invalid subdomain format
|
||||||
|
echo -n "Test 8: Invalid subdomain format... "
|
||||||
|
log_verbose "Running: curl -v -X POST http://localhost:8080/api/claim with invalid subdomain"
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
RESPONSE=$(curl -v -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"ab"}' 2>&1)
|
||||||
|
echo "$RESPONSE" | tee /tmp/test8.log
|
||||||
|
else
|
||||||
|
RESPONSE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"ab"}')
|
||||||
|
fi
|
||||||
|
log_verbose "Response: $RESPONSE"
|
||||||
|
if echo "$RESPONSE" | grep -q 'Invalid'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== All tests passed! ===${NC}"
|
||||||
|
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
echo -e "${BLUE}=== Detailed logs saved to: ===${NC}"
|
||||||
|
ls -la /tmp/test*.log 2>/dev/null || echo "No detailed logs found"
|
||||||
|
fi
|
||||||
250
tests/integration/full-integration-test.sh
Executable file
250
tests/integration/full-integration-test.sh
Executable file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Full Integration Test with Real Technitium DNS
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Full Integration Test with Technitium DNS"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
COMPOSE_FILE="docker-compose.integration.yml"
|
||||||
|
TEST_TIMEOUT=120
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Detect compose command
|
||||||
|
if command -v podman-compose &> /dev/null; then
|
||||||
|
COMPOSE_CMD="podman-compose"
|
||||||
|
echo "Using podman-compose"
|
||||||
|
elif command -v docker-compose &> /dev/null; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
echo "Using docker-compose"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: Neither podman-compose nor docker-compose found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
echo -e "${YELLOW}Cleaning up...${NC}"
|
||||||
|
$COMPOSE_CMD -f $COMPOSE_FILE down -v 2>/dev/null || true
|
||||||
|
rm -f .env.integration
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Step 1: Starting Technitium DNS + DDNS services..."
|
||||||
|
echo "This will take 30-60 seconds for Technitium to initialize..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
$COMPOSE_CMD -f $COMPOSE_FILE up -d --build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Step 2: Waiting for services to be ready..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for both services
|
||||||
|
for i in $(seq 1 $TEST_TIMEOUT); do
|
||||||
|
DYN_READY=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/ 2>/dev/null || echo "000")
|
||||||
|
TECH_READY=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5380/ 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$DYN_READY" = "200" ] && [ "$TECH_READY" = "200" ]; then
|
||||||
|
echo -e "${GREEN}Both services are ready!${NC}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $i -eq $TEST_TIMEOUT ]; then
|
||||||
|
echo -e "${RED}Timeout waiting for services${NC}"
|
||||||
|
echo "Dyn status: $DYN_READY"
|
||||||
|
echo "Tech status: $TECH_READY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $((i % 10)) -eq 0 ]; then
|
||||||
|
echo " ...waiting ($i seconds)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Give Technitium a bit more time to initialize
|
||||||
|
echo "Giving Technitium time to initialize..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Initialize Technitium - create zone and API token
|
||||||
|
echo ""
|
||||||
|
echo "Step 3: Initializing Technitium DNS..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for Technitium API to be fully ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s http://localhost:5380/api/status > /dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create the zone
|
||||||
|
echo "Creating zone 'space.test.rip'..."
|
||||||
|
ZONE_CREATE=$(curl -s -X POST http://localhost:5380/api/zones/create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"zone":"space.test.rip","type":"Primary"}' 2>/dev/null || echo '{"status":"error"}')
|
||||||
|
|
||||||
|
if echo "$ZONE_CREATE" | grep -q '"status":"ok"' || echo "$ZONE_CREATE" | grep -q 'already exists'; then
|
||||||
|
echo -e "${GREEN}Zone created or already exists${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Warning: Zone creation result: $ZONE_CREATE${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create API token
|
||||||
|
echo "Creating API token..."
|
||||||
|
TOKEN_CREATE=$(curl -s -X POST http://localhost:5380/api/user/createApiToken \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","tokenName":"ddns-bridge","token":"dns-api-token-12345"}' 2>/dev/null || echo '{"status":"error"}')
|
||||||
|
|
||||||
|
if echo "$TOKEN_CREATE" | grep -q '"status":"ok"' || echo "$TOKEN_CREATE" | grep -q 'already exists'; then
|
||||||
|
echo -e "${GREEN}API token created or already exists${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Warning: Token creation result: $TOKEN_CREATE${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Running Integration Tests"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 1: Health check
|
||||||
|
echo -n "Test 1: Health check... "
|
||||||
|
RESPONSE=$(curl -s http://localhost:8080/)
|
||||||
|
if echo "$RESPONSE" | grep -q "DWS Dynamic DNS"; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Debug health endpoint
|
||||||
|
echo -n "Test 2: Debug health endpoint... "
|
||||||
|
HEALTH=$(curl -s http://localhost:8080/health)
|
||||||
|
if echo "$HEALTH" | grep -q '"status":"healthy"'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
echo " Health: $HEALTH"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $HEALTH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Test DNS connectivity
|
||||||
|
echo -n "Test 3: DNS connectivity test... "
|
||||||
|
DNS_TEST=$(curl -s http://localhost:8080/debug/test-dns)
|
||||||
|
if echo "$DNS_TEST" | grep -q '"overall":"success"'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
echo " DNS Test: Successfully created and deleted test record"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $DNS_TEST"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: Claim a space
|
||||||
|
echo -n "Test 4: Claim a space... "
|
||||||
|
CLAIM_RESPONSE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"myhome"}')
|
||||||
|
|
||||||
|
if echo "$CLAIM_RESPONSE" | grep -q '"token"'; then
|
||||||
|
TOKEN=$(echo "$CLAIM_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
echo -e "${GREEN}PASS${NC} (token: ${TOKEN:0:25}...)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $CLAIM_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: DynDNS update - THIS IS THE CRITICAL TEST
|
||||||
|
echo ""
|
||||||
|
echo -n "Test 5: DynDNS update (CRITICAL - Real DNS)... "
|
||||||
|
UPDATE_RESPONSE=$(curl -s -u "none:$TOKEN" \
|
||||||
|
"http://localhost:8080/api/nic/update?hostname=myhome.space.test.rip&myip=203.0.113.50")
|
||||||
|
|
||||||
|
echo "Response: $UPDATE_RESPONSE"
|
||||||
|
|
||||||
|
if echo "$UPDATE_RESPONSE" | grep -qE "(good|nochg)"; then
|
||||||
|
echo -e "${GREEN}PASS${NC} - DNS update successful!"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - DNS update failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Verify DNS record was actually created
|
||||||
|
echo ""
|
||||||
|
echo -n "Test 6: Verify DNS record exists... "
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Query Technitium's API for the record
|
||||||
|
RECORD_CHECK=$(curl -s "http://localhost:5380/api/dns/records/get?domain=myhome.space.test.rip" \
|
||||||
|
-H "Authorization: Basic dns-api-token-12345")
|
||||||
|
|
||||||
|
if echo "$RECORD_CHECK" | grep -q "203.0.113.50"; then
|
||||||
|
echo -e "${GREEN}PASS${NC} - DNS record verified in Technitium!"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}WARN${NC} - Could not verify DNS record, but update reported success"
|
||||||
|
echo " Record check response: $RECORD_CHECK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 7: Test wildcard record
|
||||||
|
echo ""
|
||||||
|
echo -n "Test 7: DynDNS update (wildcard test)... "
|
||||||
|
WILDCARD_RESPONSE=$(curl -s -u "none:$TOKEN" \
|
||||||
|
"http://localhost:8080/api/nic/update?hostname=myhome.space.test.rip&myip=203.0.113.51")
|
||||||
|
|
||||||
|
if echo "$WILDCARD_RESPONSE" | grep -qE "(good|nochg)"; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $WILDCARD_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 8: Profanity filter
|
||||||
|
echo -n "Test 8: Profanity filter... "
|
||||||
|
PROFANE=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"fuck"}')
|
||||||
|
|
||||||
|
if echo "$PROFANE" | grep -q 'inappropriate'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $PROFANE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 9: Custom filter
|
||||||
|
echo -n "Test 9: Custom DWS filter... "
|
||||||
|
RESERVED=$(curl -s -X POST http://localhost:8080/api/claim \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"subdomain":"dws"}')
|
||||||
|
|
||||||
|
if echo "$RESERVED" | grep -q 'reserved'; then
|
||||||
|
echo -e "${GREEN}PASS${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}FAIL${NC} - $RESERVED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo -e "${GREEN}ALL TESTS PASSED!${NC}"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " - Health checks: Working"
|
||||||
|
echo " - DNS connectivity: Working"
|
||||||
|
echo " - Space claiming: Working"
|
||||||
|
echo " - DynDNS updates: Working (REAL DNS)"
|
||||||
|
echo " - DNS records verified: Created in Technitium"
|
||||||
|
echo " - Filtering: Working"
|
||||||
|
echo ""
|
||||||
|
echo "The integration is fully functional!"
|
||||||
439
tests/integration/integration_test.go
Normal file
439
tests/integration/integration_test.go
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"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/handlers"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/models"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/testutil"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupIntegrationTest(t *testing.T) (*gin.Engine, *database.DB, *testutil.MockTechnitiumServer, func()) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Create temp database
|
||||||
|
tmpDB := "/tmp/test_integration_" + time.Now().Format("20060102150405") + ".db"
|
||||||
|
db, err := database.New(tmpDB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create mock Technitium server
|
||||||
|
mockDNS := testutil.NewMockTechnitiumServer()
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
cfg := &config.Config{
|
||||||
|
BaseDomain: "test.rip",
|
||||||
|
SpaceSubdomain: "space",
|
||||||
|
ServerPort: "8080",
|
||||||
|
RateLimitPerIP: 100,
|
||||||
|
RateLimitPerToken: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DNS client pointing to mock
|
||||||
|
dnsClient := dns.NewClient(
|
||||||
|
mockDNS.URL(),
|
||||||
|
mockDNS.Token,
|
||||||
|
mockDNS.Username,
|
||||||
|
mockDNS.Password,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup Gin router
|
||||||
|
router := gin.New()
|
||||||
|
router.LoadHTMLGlob("../../web/templates/*")
|
||||||
|
|
||||||
|
webHandler := handlers.NewWebHandler(db, cfg)
|
||||||
|
dynHandler := handlers.NewDynDNSHandler(db, dnsClient, cfg)
|
||||||
|
|
||||||
|
router.GET("/", webHandler.Index)
|
||||||
|
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.GET("/check", webHandler.CheckSubdomain)
|
||||||
|
api.POST("/claim", webHandler.ClaimSpace)
|
||||||
|
api.GET("/nic/update", dynHandler.Update)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
mockDNS.Close()
|
||||||
|
os.Remove(tmpDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router, db, mockDNS, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ClaimSpace(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantStatus int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful claim",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate claim",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantStatus: http.StatusConflict,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profane subdomain blocked",
|
||||||
|
subdomain: "fuck",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reserved subdomain blocked",
|
||||||
|
subdomain: "dws",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
subdomain: "ab",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{
|
||||||
|
Subdomain: tt.subdomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStatus, w.Code)
|
||||||
|
|
||||||
|
if !tt.wantError {
|
||||||
|
var resp models.CreateSpaceResponse
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, resp.Token)
|
||||||
|
assert.Equal(t, tt.subdomain, resp.Subdomain)
|
||||||
|
assert.Equal(t, tt.subdomain+".space.test.rip", resp.FQDN)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_CheckSubdomain(t *testing.T) {
|
||||||
|
router, db, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a space first
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := db.CreateSpace(ctx, "existing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantAvailable bool
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "available subdomain",
|
||||||
|
subdomain: "newspace",
|
||||||
|
wantAvailable: true,
|
||||||
|
wantReason: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "existing",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profane subdomain",
|
||||||
|
subdomain: "shit",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "inappropriate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reserved subdomain",
|
||||||
|
subdomain: "dubey",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "reserved",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/check?subdomain="+tt.subdomain, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantAvailable, resp["available"])
|
||||||
|
if tt.wantReason != "" {
|
||||||
|
assert.Equal(t, tt.wantReason, resp["reason"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_DynDNSUpdate(t *testing.T) {
|
||||||
|
router, db, mockDNS, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a space
|
||||||
|
ctx := context.Background()
|
||||||
|
space, err := db.CreateSpace(ctx, "myrouter")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create basic auth header
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("none:" + space.Token))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
myip string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful update with IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.100",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "good 192.168.1.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same IP - no change",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.100",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "nochg 192.168.1.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "10.0.0.50",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "good 10.0.0.50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing auth",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "",
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "badauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("none:invalid-token")),
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "badauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong hostname",
|
||||||
|
hostname: "wrong.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "nohost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hostname",
|
||||||
|
hostname: "",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "nohost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "not-an-ip",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "dnserr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
url := "/api/nic/update?hostname=" + tt.hostname
|
||||||
|
if tt.myip != "" {
|
||||||
|
url += "&myip=" + tt.myip
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if tt.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tt.authHeader)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStatus, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), tt.wantBody)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DNS records were created
|
||||||
|
records := mockDNS.GetRecords()
|
||||||
|
assert.Len(t, records, 2) // A record + wildcard record
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_FullWorkflow(t *testing.T) {
|
||||||
|
router, _, mockDNS, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Step 1: Check if subdomain is available
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/check?subdomain=myhome", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var checkResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &checkResp)
|
||||||
|
assert.True(t, checkResp["available"].(bool))
|
||||||
|
|
||||||
|
// Step 2: Claim the space
|
||||||
|
claimBody, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: "myhome"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(claimBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
var claimResp models.CreateSpaceResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &claimResp)
|
||||||
|
token := claimResp.Token
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
// Step 3: Update DNS via DynDNS protocol
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("none:" + token))
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/nic/update?hostname=myhome.space.test.rip&myip=1.2.3.4", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic "+auth)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "good 1.2.3.4")
|
||||||
|
|
||||||
|
// Step 4: Verify DNS records were created in mock server
|
||||||
|
records := mockDNS.GetRecords()
|
||||||
|
assert.Len(t, records, 2)
|
||||||
|
|
||||||
|
// Check for A record
|
||||||
|
aRecord, exists := records["myhome.space.test.rip:A"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "1.2.3.4", aRecord.IPAddress)
|
||||||
|
|
||||||
|
// Check for wildcard record
|
||||||
|
wildcardRecord, exists := records["*.myhome.space.test.rip:A"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "1.2.3.4", wildcardRecord.IPAddress)
|
||||||
|
|
||||||
|
// Step 5: Try to claim same subdomain (should fail)
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(claimBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ProfanityFiltering(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
profaneSubdomains := []string{
|
||||||
|
"fuck",
|
||||||
|
"shit",
|
||||||
|
"ass",
|
||||||
|
"bitch",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdomain := range profaneSubdomains {
|
||||||
|
t.Run(subdomain, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: subdomain})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
var resp map[string]string
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Contains(t, resp["error"], "inappropriate")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_CustomFilter(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reservedSubdomains := []string{
|
||||||
|
"dws",
|
||||||
|
"dubey",
|
||||||
|
"tanishq",
|
||||||
|
"tdubey",
|
||||||
|
"dub3y",
|
||||||
|
"t4nishq",
|
||||||
|
"dwsengineering",
|
||||||
|
"dubeydns",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdomain := range reservedSubdomains {
|
||||||
|
t.Run(subdomain, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: subdomain})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
var resp map[string]string
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Contains(t, resp["error"], "reserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/integration/technitium-init.sh
Executable file
42
tests/integration/technitium-init.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Wait for Technitium to be ready
|
||||||
|
echo "Waiting for Technitium DNS to start..."
|
||||||
|
while ! wget -q --spider http://localhost:5380/ 2>/dev/null; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Technitium is up, configuring..."
|
||||||
|
|
||||||
|
# Login and get session
|
||||||
|
curl -s -X POST http://localhost:5380/api/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin123"}' > /tmp/login.json
|
||||||
|
|
||||||
|
if [ ! -f /tmp/login.json ]; then
|
||||||
|
echo "Failed to login to Technitium"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the zone 'space.test.rip'
|
||||||
|
echo "Creating zone space.test.rip..."
|
||||||
|
curl -s -X POST http://localhost:5380/api/zones/create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"zone": "space.test.rip",
|
||||||
|
"type": "Primary"
|
||||||
|
}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create API token for DDNS service
|
||||||
|
echo "Creating API token..."
|
||||||
|
curl -s -X POST http://localhost:5380/api/user/createApiToken \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "admin",
|
||||||
|
"tokenName": "ddns-bridge",
|
||||||
|
"token": "dns-api-token-12345"
|
||||||
|
}'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Technitium initialization complete!"
|
||||||
@@ -33,6 +33,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
availabilityStatus.textContent = '✓ Available';
|
availabilityStatus.textContent = '✓ Available';
|
||||||
availabilityStatus.className = 'status available';
|
availabilityStatus.className = 'status available';
|
||||||
claimBtn.disabled = false;
|
claimBtn.disabled = false;
|
||||||
|
} else if (data.reason === 'inappropriate') {
|
||||||
|
availabilityStatus.textContent = '✗ Contains inappropriate content';
|
||||||
|
availabilityStatus.className = 'status taken';
|
||||||
|
claimBtn.disabled = true;
|
||||||
|
} else if (data.reason === 'reserved') {
|
||||||
|
availabilityStatus.textContent = '✗ This subdomain is reserved';
|
||||||
|
availabilityStatus.className = 'status taken';
|
||||||
|
claimBtn.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
availabilityStatus.textContent = '✗ Already taken';
|
availabilityStatus.textContent = '✗ Already taken';
|
||||||
availabilityStatus.className = 'status taken';
|
availabilityStatus.className = 'status taken';
|
||||||
|
|||||||
Reference in New Issue
Block a user