Initial commit: DDNS service with NIC V2 protocol support
Features: - Token-based subdomain claiming - NIC V2 (DynDNS2) protocol implementation - Technitium DNS integration - Rate limiting (10 req/min IP, 1 req/min token) - Web UI for space claiming - Docker/Docker Compose support - Compatible with UniFi, pfSense, EdgeRouter Module: git.dws.rip/DWS/dyn
This commit is contained in:
83
internal/config/config.go
Normal file
83
internal/config/config.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerPort string
|
||||
DatabasePath string
|
||||
TechnitiumURL string
|
||||
TechnitiumUsername string
|
||||
TechnitiumPassword string
|
||||
TechnitiumToken string
|
||||
BaseDomain string
|
||||
SpaceSubdomain string
|
||||
RateLimitPerIP int
|
||||
RateLimitPerToken int
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabasePath: getEnv("DATABASE_PATH", "./dyn.db"),
|
||||
TechnitiumURL: getEnv("TECHNITIUM_URL", ""),
|
||||
TechnitiumUsername: getEnv("TECHNITIUM_USERNAME", ""),
|
||||
TechnitiumPassword: getEnv("TECHNITIUM_PASSWORD", ""),
|
||||
TechnitiumToken: getEnv("TECHNITIUM_TOKEN", ""),
|
||||
BaseDomain: getEnv("BASE_DOMAIN", "dws.rip"),
|
||||
SpaceSubdomain: getEnv("SPACE_SUBDOMAIN", "space"),
|
||||
RateLimitPerIP: getEnvAsInt("RATE_LIMIT_PER_IP", 10),
|
||||
RateLimitPerToken: getEnvAsInt("RATE_LIMIT_PER_TOKEN", 1),
|
||||
TrustedProxies: getEnvAsSlice("TRUSTED_PROXIES", []string{}),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (c *Config) Validate() []string {
|
||||
var errors []string
|
||||
|
||||
if c.TechnitiumURL == "" {
|
||||
errors = append(errors, "TECHNITIUM_URL is required")
|
||||
}
|
||||
|
||||
if c.TechnitiumToken == "" && (c.TechnitiumUsername == "" || c.TechnitiumPassword == "") {
|
||||
errors = append(errors, "Either TECHNITIUM_TOKEN or TECHNITIUM_USERNAME/PASSWORD must be provided")
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func (c *Config) GetZone() string {
|
||||
if c.SpaceSubdomain == "" {
|
||||
return c.BaseDomain
|
||||
}
|
||||
return c.SpaceSubdomain + "." + c.BaseDomain
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intVal, err := strconv.Atoi(value); err == nil {
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsSlice(key string, defaultValue []string) []string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return strings.Split(value, ",")
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
219
internal/database/db.go
Normal file
219
internal/database/db.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/DWS/dyn/internal/models"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func New(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
token TEXT PRIMARY KEY,
|
||||
subdomain TEXT UNIQUE NOT NULL,
|
||||
last_ip TEXT,
|
||||
updated_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subdomain ON spaces(subdomain);
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
space := &models.Space{
|
||||
Token: token,
|
||||
Subdomain: subdomain,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO spaces (token, subdomain, last_ip, updated_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = db.conn.ExecContext(ctx, query,
|
||||
space.Token,
|
||||
space.Subdomain,
|
||||
space.LastIP,
|
||||
space.UpdatedAt,
|
||||
space.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, fmt.Errorf("subdomain already taken")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create space: %w", err)
|
||||
}
|
||||
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetSpaceByToken(ctx context.Context, token string) (*models.Space, error) {
|
||||
query := `
|
||||
SELECT token, subdomain, last_ip, updated_at, created_at
|
||||
FROM spaces
|
||||
WHERE token = ?
|
||||
`
|
||||
|
||||
row := db.conn.QueryRowContext(ctx, query, token)
|
||||
|
||||
space := &models.Space{}
|
||||
var updatedAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&space.Token,
|
||||
&space.Subdomain,
|
||||
&space.LastIP,
|
||||
&updatedAt,
|
||||
&space.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get space: %w", err)
|
||||
}
|
||||
|
||||
if updatedAt.Valid {
|
||||
space.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetSpaceBySubdomain(ctx context.Context, subdomain string) (*models.Space, error) {
|
||||
query := `
|
||||
SELECT token, subdomain, last_ip, updated_at, created_at
|
||||
FROM spaces
|
||||
WHERE subdomain = ?
|
||||
`
|
||||
|
||||
row := db.conn.QueryRowContext(ctx, query, subdomain)
|
||||
|
||||
space := &models.Space{}
|
||||
var updatedAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&space.Token,
|
||||
&space.Subdomain,
|
||||
&space.LastIP,
|
||||
&updatedAt,
|
||||
&space.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get space: %w", err)
|
||||
}
|
||||
|
||||
if updatedAt.Valid {
|
||||
space.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
|
||||
return space, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateSpaceIP(ctx context.Context, token string, ip string) error {
|
||||
query := `
|
||||
UPDATE spaces
|
||||
SET last_ip = ?, updated_at = ?
|
||||
WHERE token = ?
|
||||
`
|
||||
|
||||
_, err := db.conn.ExecContext(ctx, query, ip, time.Now(), token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update space IP: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SubdomainExists(ctx context.Context, subdomain string) (bool, error) {
|
||||
query := `SELECT 1 FROM spaces WHERE subdomain = ?`
|
||||
row := db.conn.QueryRowContext(ctx, query, subdomain)
|
||||
|
||||
var exists int
|
||||
err := row.Scan(&exists)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check subdomain: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return contains(errStr, "UNIQUE constraint failed") ||
|
||||
contains(errStr, "duplicate key value")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || containsInternal(s, substr))
|
||||
}
|
||||
|
||||
func containsInternal(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
184
internal/dns/technitium.go
Normal file
184
internal/dns/technitium.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
username string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type AddRecordRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"`
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
TTL int `json:"ttl,omitempty"`
|
||||
}
|
||||
|
||||
type AddRecordResponse struct {
|
||||
Status string `json:"status"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
Error string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
Status string `json:"status"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token, username, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
username: username,
|
||||
password: password,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) AddARecord(zone, hostname, ip string, ttl int) error {
|
||||
domain := fmt.Sprintf("%s.%s", hostname, zone)
|
||||
|
||||
reqBody := AddRecordRequest{
|
||||
Domain: domain,
|
||||
Type: "A",
|
||||
IPAddress: ip,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
reqBody.TTL = ttl
|
||||
}
|
||||
|
||||
return c.addRecord(reqBody)
|
||||
}
|
||||
|
||||
func (c *Client) AddWildcardARecord(zone, hostname, ip string, ttl int) error {
|
||||
domain := fmt.Sprintf("*.%s.%s", hostname, zone)
|
||||
|
||||
reqBody := AddRecordRequest{
|
||||
Domain: domain,
|
||||
Type: "A",
|
||||
IPAddress: ip,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
reqBody.TTL = ttl
|
||||
}
|
||||
|
||||
return c.addRecord(reqBody)
|
||||
}
|
||||
|
||||
func (c *Client) addRecord(req AddRecordRequest) error {
|
||||
endpoint := fmt.Sprintf("%s/api/dns/records/add", c.baseURL)
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("domain", req.Domain)
|
||||
formData.Set("type", req.Type)
|
||||
formData.Set("ipAddress", req.IPAddress)
|
||||
formData.Set("overwrite", fmt.Sprintf("%t", req.Overwrite))
|
||||
if req.TTL > 0 {
|
||||
formData.Set("ttl", fmt.Sprintf("%d", req.TTL))
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if c.token != "" {
|
||||
httpReq.Header.Set("Authorization", "Basic "+c.token)
|
||||
} else if c.username != "" && c.password != "" {
|
||||
httpReq.SetBasicAuth(c.username, c.password)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Status != "ok" {
|
||||
if apiResp.Error != nil {
|
||||
return fmt.Errorf("API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("API error: status not ok")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteRecord(zone, hostname, recordType string) error {
|
||||
endpoint := fmt.Sprintf("%s/api/dns/records/delete", c.baseURL)
|
||||
|
||||
domain := fmt.Sprintf("%s.%s", hostname, zone)
|
||||
if hostname == "" || hostname == "@" {
|
||||
domain = zone
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("domain", domain)
|
||||
formData.Set("type", recordType)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if c.token != "" {
|
||||
httpReq.Header.Set("Authorization", "Basic "+c.token)
|
||||
} else if c.username != "" && c.password != "" {
|
||||
httpReq.SetBasicAuth(c.username, c.password)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
229
internal/handlers/handlers.go
Normal file
229
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/DWS/dyn/internal/config"
|
||||
"git.dws.rip/DWS/dyn/internal/database"
|
||||
"git.dws.rip/DWS/dyn/internal/dns"
|
||||
"git.dws.rip/DWS/dyn/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
db *database.DB
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewWebHandler(db *database.DB, cfg *config.Config) *WebHandler {
|
||||
return &WebHandler{
|
||||
db: db,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"baseDomain": h.config.BaseDomain,
|
||||
"spaceSubdomain": h.config.SpaceSubdomain,
|
||||
"zone": h.config.GetZone(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ClaimSpace(c *gin.Context) {
|
||||
var req models.CreateSpaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
subdomain := strings.ToLower(strings.TrimSpace(req.Subdomain))
|
||||
|
||||
if !isValidSubdomain(subdomain) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := h.db.SubdomainExists(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain availability"})
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Subdomain already taken"})
|
||||
return
|
||||
}
|
||||
|
||||
space, err := h.db.CreateSpace(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create space"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := models.CreateSpaceResponse{
|
||||
Token: space.Token,
|
||||
Subdomain: space.Subdomain,
|
||||
FQDN: space.GetFQDN(h.config.GetZone()),
|
||||
CreatedAt: space.CreatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *WebHandler) CheckSubdomain(c *gin.Context) {
|
||||
subdomain := strings.ToLower(strings.TrimSpace(c.Query("subdomain")))
|
||||
|
||||
if subdomain == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Subdomain parameter required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := h.db.SubdomainExists(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"available": !exists,
|
||||
"subdomain": subdomain,
|
||||
})
|
||||
}
|
||||
|
||||
func isValidSubdomain(subdomain string) bool {
|
||||
if len(subdomain) < 3 || len(subdomain) > 63 {
|
||||
return false
|
||||
}
|
||||
|
||||
if subdomain[0] == '-' || subdomain[len(subdomain)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range subdomain {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type DynDNSHandler struct {
|
||||
db *database.DB
|
||||
dns *dns.Client
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewDynDNSHandler(db *database.DB, dnsClient *dns.Client, cfg *config.Config) *DynDNSHandler {
|
||||
return &DynDNSHandler{
|
||||
db: db,
|
||||
dns: dnsClient,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DynDNSHandler) Update(c *gin.Context) {
|
||||
token, err := extractBasicAuthPassword(c)
|
||||
if err != nil {
|
||||
c.String(http.StatusUnauthorized, "badauth")
|
||||
return
|
||||
}
|
||||
|
||||
hostname := c.Query("hostname")
|
||||
myip := c.Query("myip")
|
||||
|
||||
if hostname == "" {
|
||||
c.String(http.StatusBadRequest, "nohost")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
space, err := h.db.GetSpaceByToken(ctx, token)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
if space == nil {
|
||||
c.String(http.StatusUnauthorized, "badauth")
|
||||
return
|
||||
}
|
||||
|
||||
expectedFQDN := space.GetFQDN(h.config.GetZone())
|
||||
if hostname != expectedFQDN {
|
||||
c.String(http.StatusBadRequest, "nohost")
|
||||
return
|
||||
}
|
||||
|
||||
if myip == "" {
|
||||
myip = c.ClientIP()
|
||||
}
|
||||
|
||||
if net.ParseIP(myip) == nil {
|
||||
c.String(http.StatusBadRequest, "dnserr")
|
||||
return
|
||||
}
|
||||
|
||||
zone := h.config.GetZone()
|
||||
|
||||
err = h.dns.AddARecord(zone, space.Subdomain, myip, 300)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.dns.AddWildcardARecord(zone, space.Subdomain, myip, 300)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
if space.LastIP == myip {
|
||||
c.String(http.StatusOK, "nochg %s", myip)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.UpdateSpaceIP(ctx, token, myip)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "good %s", myip)
|
||||
}
|
||||
|
||||
func extractBasicAuthPassword(c *gin.Context) (string, error) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return parts[1], nil
|
||||
}
|
||||
112
internal/middleware/ratelimit.go
Normal file
112
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RateLimiter struct {
|
||||
ipLimits map[string]*RateLimitEntry
|
||||
tokenLimits map[string]*RateLimitEntry
|
||||
mu sync.RWMutex
|
||||
limitPerIP int
|
||||
limitPerToken int
|
||||
}
|
||||
|
||||
type RateLimitEntry struct {
|
||||
Count int
|
||||
ResetTime time.Time
|
||||
}
|
||||
|
||||
func NewRateLimiter(perIP, perToken int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
ipLimits: make(map[string]*RateLimitEntry),
|
||||
tokenLimits: make(map[string]*RateLimitEntry),
|
||||
limitPerIP: perIP,
|
||||
limitPerToken: perToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
|
||||
rl.mu.Lock()
|
||||
entry, exists := rl.ipLimits[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(entry.ResetTime) {
|
||||
rl.ipLimits[ip] = &RateLimitEntry{
|
||||
Count: 1,
|
||||
ResetTime: now.Add(time.Minute),
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if entry.Count >= rl.limitPerIP {
|
||||
rl.mu.Unlock()
|
||||
c.String(http.StatusTooManyRequests, "rate limit exceeded")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
entry.Count++
|
||||
rl.mu.Unlock()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) RateLimitByToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
entry, exists := rl.tokenLimits[token]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(entry.ResetTime) {
|
||||
rl.tokenLimits[token] = &RateLimitEntry{
|
||||
Count: 1,
|
||||
ResetTime: now.Add(time.Minute),
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if entry.Count >= rl.limitPerToken {
|
||||
rl.mu.Unlock()
|
||||
c.String(http.StatusTooManyRequests, "rate limit exceeded")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
entry.Count++
|
||||
rl.mu.Unlock()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Basic" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[1]
|
||||
}
|
||||
35
internal/models/space.go
Normal file
35
internal/models/space.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Space struct {
|
||||
Token string `json:"token" db:"token"`
|
||||
Subdomain string `json:"subdomain" db:"subdomain"`
|
||||
LastIP string `json:"last_ip" db:"last_ip"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
type CreateSpaceRequest struct {
|
||||
Subdomain string `json:"subdomain" binding:"required,alphanumdash,min=3,max=63"`
|
||||
}
|
||||
|
||||
type CreateSpaceResponse struct {
|
||||
Token string `json:"token"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
FQDN string `json:"fqdn"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SpaceInfo struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
LastIP string `json:"last_ip"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Space) GetFQDN(zone string) string {
|
||||
return s.Subdomain + "." + zone
|
||||
}
|
||||
Reference in New Issue
Block a user