Files
FeatherDDNS/main.go
2026-01-28 23:03:50 +01:00

206 lines
4.5 KiB
Go

package main
import (
"FeatherDDNS/cache"
"FeatherDDNS/dns"
_ "FeatherDDNS/dns"
"FeatherDDNS/ratelimit"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Config struct {
ListenPort string `json:"listenPort"`
AdminToken string `json:"adminToken"`
RateLimit RateLimit `json:"ratelimit"`
ApiTokens ApiTokens `json:"apiTokens"`
Clients []Client `json:"clients"`
}
type RateLimit struct {
MaxTokens int `json:"maxTokens"`
RefillRate int `json:"refillRate"`
TokensPerHit int `json:"tokensPerHit"`
}
type ApiTokens struct {
Cloudflare string `json:"cloudflare"`
}
type Client struct {
FQDN string `json:"fqdn"`
Token string `json:"token"`
DNSProvider string `json:"dnsProvider"`
ZoneId string `json:"zoneId"`
}
var config Config
// Should have CF_API_TOKEN env set
func main() {
fmt.Println("Starting Feather DDNS...")
startUS := time.Now().UnixMicro()
loadConfig()
gin.SetMode(gin.ReleaseMode)
rl := ratelimit.NewRateLimiter(
config.RateLimit.MaxTokens,
time.Duration(config.RateLimit.RefillRate)*time.Second,
config.RateLimit.TokensPerHit,
)
defer rl.Stop()
engine := gin.New()
engine.Use(gin.Logger(), gin.Recovery())
engine.Use(rl.Middleware())
engine.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
engine.GET("/token/generate", func(ctx *gin.Context) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{
"message": err.Error(),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"token": base64.URLEncoding.EncodeToString(b),
})
})
engine.GET("/reload", func(ctx *gin.Context) {
authHeader := ctx.GetHeader("Authorization")
authToken := strings.TrimPrefix(authHeader, "Bearer ")
if authToken == config.AdminToken {
loadConfig()
log.Println("Configuration has been reloaded")
ctx.JSON(http.StatusOK, gin.H{
"message": "Configuration has been reloaded",
})
return
}
ctx.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
})
engine.GET("/update", func(ctx *gin.Context) {
fqdn := ctx.Query("fqdn")
authHeader := ctx.GetHeader("Authorization")
authToken := strings.TrimPrefix(authHeader, "Bearer ")
client, err := getClient(fqdn)
if err != nil {
log.Println("Error: " + err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
return
}
if authToken == client.Token {
var ip string
if strings.TrimSpace(ctx.GetHeader("X-Source-IP")) == "" {
ip = ctx.ClientIP()
} else {
ip = strings.TrimSpace(ctx.GetHeader("X-Source-IP"))
fmt.Println("Using IP from header")
}
fmt.Printf("Request to update %s to IP %s\n", fqdn, ip)
if !cache.GetCache().NeedsUpdate(fqdn, client.DNSProvider, ip) {
ctx.JSON(http.StatusOK, gin.H{
"message": "IP unchanged, skipped",
})
return
}
switch client.DNSProvider {
case "cloudflare":
{
err = dns.UpdateCloudflare(config.ApiTokens.Cloudflare, client.ZoneId, client.FQDN, ip)
break
}
}
if err != nil {
log.Println("Error: " + err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"message": "Internal Server Error, check logs for more info",
})
return
}
cache.GetCache().Set(fqdn, client.DNSProvider, ip)
ctx.JSON(http.StatusOK, gin.H{
"message": "Updated " + fqdn + " to resolve to " + ip,
})
return
}
ctx.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
})
usElapsed := time.Now().UnixMicro() - startUS
fmt.Println("Feather DDNS started in " + strconv.Itoa(int(usElapsed)) + "us")
fmt.Println("Running on port " + config.ListenPort)
err := engine.Run(":" + config.ListenPort)
if err != nil {
panic(err)
}
}
func loadConfig() {
config = Config{}
var cfgFile *os.File
var err error
if os.Getenv("FEATHERDDNS_DEBUG") == "true" {
cfgFile, err = os.Open("./config.json")
} else {
cfgFile, err = os.Open("/etc/featherddns/config.json")
}
if err != nil {
panic(err)
}
defer cfgFile.Close()
rawJson, err := io.ReadAll(cfgFile)
if err != nil {
panic(err)
}
err = json.Unmarshal(rawJson, &config)
if err != nil {
panic(err)
}
}
func getClient(fqdn string) (*Client, error) {
for _, client := range config.Clients {
if client.FQDN == fqdn {
return &client, nil
}
}
return &Client{}, errors.New("client not found")
}