diff --git a/.gitignore b/.gitignore
index 08af5e3..26481f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,3 +115,7 @@ fabric.properties
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
+bin/
+build/
+dist/
+config.json
diff --git a/.idea/FeatherDDNS.iml b/.idea/FeatherDDNS.iml
new file mode 100644
index 0000000..7ee078d
--- /dev/null
+++ b/.idea/FeatherDDNS.iml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml
new file mode 100644
index 0000000..d7202f0
--- /dev/null
+++ b/.idea/go.imports.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..106f2c3
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile.debian-builder b/Dockerfile.debian-builder
new file mode 100644
index 0000000..4e41c6c
--- /dev/null
+++ b/Dockerfile.debian-builder
@@ -0,0 +1,33 @@
+# Dockerfile.debian-builder
+# Debian-based container for building .deb packages from pre-built Go binaries
+#
+# This image contains only Debian packaging tools - NO build tools like gcc or golang.
+# The Go binaries are built on the Fedora host and mounted into this container.
+
+FROM debian:bookworm-slim
+
+LABEL maintainer="Steven Tracey "
+LABEL description="FeatherDDNS Debian package builder"
+
+# Install Debian packaging tools only (no build toolchain)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ debhelper \
+ devscripts \
+ dpkg-dev \
+ fakeroot \
+ lintian \
+ gzip \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create working directories
+RUN mkdir -p /build /dist
+
+# Set working directory
+WORKDIR /build
+
+# Run as root for packaging operations (writing to mounted volumes)
+# This is safe since we're only packaging pre-built binaries
+
+# Default command shows help
+CMD ["echo", "Use: podman run ... debian-builder /build/scripts/build-deb.sh"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..2915070
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,26 @@
+.PHONY: build package publish clean
+
+# Variables
+DIST_DIR := dist
+VERSION := 1.0.2
+
+build:
+ @echo "Building FeatherDDNS..."
+ @mkdir -p ./bin
+ @go build -buildmode=pie -trimpath -ldflags='-linkmode=external -extldflags="-Wl,-z,relro,-z,now" -s -w' -o ./bin/FeatherDDNS .
+
+package: build
+ @echo "Packaging FeatherDDNS using Debian container..."
+ @chmod +x scripts/package.sh scripts/build-deb.sh
+ @VERSION=$(VERSION) ./scripts/package.sh
+ @echo ""
+ @echo "Package built successfully!"
+
+publish:
+ @echo "Publishing to Gitea APT registry..."
+ @chmod +x scripts/publish-to-gitea.sh
+ @./scripts/publish-to-gitea.sh
+
+clean:
+ @echo "Cleaning build artifacts..."
+ @rm -rf ./bin ./build ./$(DIST_DIR)
diff --git a/cache/MemCache.go b/cache/MemCache.go
new file mode 100644
index 0000000..4bdb0dd
--- /dev/null
+++ b/cache/MemCache.go
@@ -0,0 +1,79 @@
+package cache
+
+import "sync"
+
+// DNSCache is a simple in-memory cache for DNS records
+type DNSCache struct {
+ mu sync.RWMutex
+ cache map[string]string // key: "fqdn:provider" -> value: IP
+}
+
+var globalCache *DNSCache
+
+func init() {
+ globalCache = &DNSCache{
+ cache: make(map[string]string),
+ }
+}
+
+// GetCache returns the global cache instance
+func GetCache() *DNSCache {
+ return globalCache
+}
+
+// makeKey creates a unique cache key from FQDN and provider
+func (c *DNSCache) makeKey(fqdn, provider string) string {
+ return fqdn + ":" + provider
+}
+
+// NeedsUpdate checks if the record needs updating
+// Returns true if IP is different or not in cache
+func (c *DNSCache) NeedsUpdate(fqdn, provider, newIP string) bool {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ key := c.makeKey(fqdn, provider)
+ cachedIP, exists := c.cache[key]
+
+ if !exists {
+ return true // Not in cache, needs update
+ }
+
+ return cachedIP != newIP // Update if IP changed
+}
+
+// Set updates the cache with the new IP
+func (c *DNSCache) Set(fqdn, provider, ip string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ key := c.makeKey(fqdn, provider)
+ c.cache[key] = ip
+}
+
+// Get retrieves the cached IP
+func (c *DNSCache) Get(fqdn, provider string) (string, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ key := c.makeKey(fqdn, provider)
+ ip, exists := c.cache[key]
+ return ip, exists
+}
+
+// Clear removes a specific entry from cache
+func (c *DNSCache) Clear(fqdn, provider string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ key := c.makeKey(fqdn, provider)
+ delete(c.cache, key)
+}
+
+// ClearAll removes all entries from cache
+func (c *DNSCache) ClearAll() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.cache = make(map[string]string)
+}
diff --git a/config.json.example b/config.json.example
new file mode 100644
index 0000000..4239315
--- /dev/null
+++ b/config.json.example
@@ -0,0 +1,26 @@
+{
+ "listenPort": "8080",
+ "adminToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=",
+ "ratelimit": {
+ "maxTokens": 30,
+ "refillRate": 2,
+ "tokensPerHit": 1
+ },
+ "apiTokens": {
+ "cloudflare": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ },
+ "clients": [
+ {
+ "fqdn": "example.com",
+ "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=",
+ "dnsProvider": "cloudflare",
+ "zoneId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ },
+ {
+ "fqdn": "sub.example.com",
+ "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=",
+ "dnsProvider": "cloudflare",
+ "zoneId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dns/cloudflare.go b/dns/cloudflare.go
new file mode 100644
index 0000000..ecebbef
--- /dev/null
+++ b/dns/cloudflare.go
@@ -0,0 +1,79 @@
+package dns
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/cloudflare/cloudflare-go/v3"
+ "github.com/cloudflare/cloudflare-go/v3/dns"
+ "github.com/cloudflare/cloudflare-go/v3/option"
+)
+
+var cfClient *cloudflare.Client
+var ctx = context.Background()
+
+func UpdateCloudflare(apiToken string, zoneId string, fqdn string, newIP string) error {
+ cfClient = cloudflare.NewClient(option.WithAPIToken(apiToken))
+ listResp, err := listAllCloudflareRecords(zoneId, fqdn)
+ if err != nil {
+ return err
+ }
+ _, err = updateCloudflareRecord(apiToken, listResp, zoneId, newIP)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func updateCloudflareRecord(apiToken string, record dns.RecordListResponse, zoneId string, newIP string) (string, error) {
+ url := "https://api.cloudflare.com/client/v4/zones/" + zoneId + "/dns_records/" + record.ID
+ jsonData := []byte(`{"content":"` + newIP + `"}`)
+ body := bytes.NewReader(jsonData)
+ req, err := http.NewRequest("PATCH", url, body)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiToken)
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ fmt.Printf("Status Code: %d\n", resp.StatusCode)
+
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", nil
+ }
+
+ respString := string(responseBody)
+
+ fmt.Printf("Response Body: %s\n", respString)
+ return respString, nil
+}
+
+func listAllCloudflareRecords(zoneId string, fqdn string) (dns.RecordListResponse, error) {
+ records, err := cfClient.DNS.Records.List(
+ ctx,
+ dns.RecordListParams{
+ ZoneID: cloudflare.F(zoneId),
+ Name: cloudflare.F(fqdn),
+ Type: cloudflare.F(dns.RecordListParamsTypeA),
+ })
+ if err != nil {
+ return dns.RecordListResponse{}, err
+ }
+ if len(records.Result) == 0 {
+ return dns.RecordListResponse{}, fmt.Errorf("no a record found for %s", fqdn)
+ }
+
+ record := records.Result[0]
+
+ return record, nil
+}
diff --git a/endpoints.http b/endpoints.http
new file mode 100644
index 0000000..2a299bd
--- /dev/null
+++ b/endpoints.http
@@ -0,0 +1,18 @@
+###
+GET http://localhost:8080/ping
+Accept: application/json
+
+###
+GET http://localhost:8080/token/generate
+Accept: application/json
+
+###
+GET http://localhost:8080/update?fqdn=test.vpn.nevets.tech
+Accept: application/json
+Authorization: Bearer _od-Evw-4_CVXY6NnSJgLnU6LEeiwY1YLJ83VRRiqdQ=
+X-Source-IP: 169.4.20.67
+
+###
+GET http://localhost:8080/reload
+Accept: application/json
+Authorization: Bearer lbmYn8XEcqr_uX1r6zsXx4ds8he8HeosIBbaX85AfAU=
\ No newline at end of file
diff --git a/featherddns.service b/featherddns.service
new file mode 100644
index 0000000..9fabffd
--- /dev/null
+++ b/featherddns.service
@@ -0,0 +1,36 @@
+[Unit]
+Description=FeatherDDNS - Lightweight Dynamic DNS Server
+Documentation=https://git.nevets.tech/Steven/FeatherDDNS
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=featherddns
+Group=featherddns
+ExecStart=/usr/bin/featherddns
+WorkingDirectory=/var/lib/featherddns
+Restart=on-failure
+RestartSec=5
+
+# Security hardening
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictSUIDSGID=yes
+RestrictNamespaces=yes
+
+# Allow binding to privileged ports if needed (DNS uses 53)
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+
+# Writable directories
+ReadWritePaths=/var/lib/featherddns /var/log/featherddns
+
+[Install]
+WantedBy=multi-user.target
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..500f496
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,47 @@
+module FeatherDDNS
+
+go 1.25
+
+require (
+ github.com/cloudflare/cloudflare-go/v3 v3.1.0
+ github.com/gin-gonic/gin v1.11.0
+)
+
+require (
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.14.1 // indirect
+ github.com/bytedance/sonic/loader v0.3.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.10 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.28.0 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/quic-go/qpack v0.5.1 // indirect
+ github.com/quic-go/quic-go v0.55.0 // indirect
+ github.com/tidwall/gjson v1.14.4 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.0 // indirect
+ go.uber.org/mock v0.6.0 // indirect
+ golang.org/x/arch v0.22.0 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/net v0.46.0 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
+ golang.org/x/tools v0.38.0 // indirect
+ google.golang.org/protobuf v1.36.10 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b4982b4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,103 @@
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
+github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cloudflare/cloudflare-go/v3 v3.1.0 h1:kVjWgd5tOeDXcO/tMpiINK62HkSAUo41bmZC8GfhK5g=
+github.com/cloudflare/cloudflare-go/v3 v3.1.0/go.mod h1:m3J2vKjPGiRy129/pq0GV4GFLNIx9Y06ASO1sqO8Bks=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
+github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
+github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
+github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
+github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d542086
--- /dev/null
+++ b/main.go
@@ -0,0 +1,205 @@
+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")
+}
diff --git a/ratelimit/ratelimit.go b/ratelimit/ratelimit.go
new file mode 100644
index 0000000..2f3d888
--- /dev/null
+++ b/ratelimit/ratelimit.go
@@ -0,0 +1,117 @@
+package ratelimit
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+type client struct {
+ tokens int
+ lastRefill time.Time
+}
+
+type RateLimiter struct {
+ clients map[string]*client
+ mu sync.RWMutex
+ maxTokens int
+ refillRate time.Duration
+ tokensPerHit int
+ cleanupTicker *time.Ticker
+}
+
+// NewRateLimiter creates a new rate limiter
+// maxTokens: maximum number of tokens a client can have
+// refillRate: how often to add tokens back
+// tokensPerHit: tokens consumed per request
+func NewRateLimiter(maxTokens int, refillRate time.Duration, tokensPerHit int) *RateLimiter {
+ rl := &RateLimiter{
+ clients: make(map[string]*client),
+ maxTokens: maxTokens,
+ refillRate: refillRate,
+ tokensPerHit: tokensPerHit,
+ }
+
+ // Cleanup old clients every 5 minutes
+ rl.cleanupTicker = time.NewTicker(5 * time.Minute)
+ go rl.cleanupClients()
+
+ return rl
+}
+
+func (rl *RateLimiter) getClient(ip string) *client {
+ rl.mu.RLock()
+ c, exists := rl.clients[ip]
+ rl.mu.RUnlock()
+
+ if !exists {
+ rl.mu.Lock()
+ c = &client{
+ tokens: rl.maxTokens,
+ lastRefill: time.Now(),
+ }
+ rl.clients[ip] = c
+ rl.mu.Unlock()
+ }
+
+ return c
+}
+
+func (rl *RateLimiter) refillTokens(c *client) {
+ now := time.Now()
+ elapsed := now.Sub(c.lastRefill)
+ tokensToAdd := int(elapsed / rl.refillRate)
+
+ if tokensToAdd > 0 {
+ c.tokens += tokensToAdd
+ if c.tokens > rl.maxTokens {
+ c.tokens = rl.maxTokens
+ }
+ c.lastRefill = now
+ }
+}
+
+func (rl *RateLimiter) cleanupClients() {
+ for range rl.cleanupTicker.C {
+ rl.mu.Lock()
+ now := time.Now()
+ for ip, c := range rl.clients {
+ if now.Sub(c.lastRefill) > 10*time.Minute {
+ delete(rl.clients, ip)
+ }
+ }
+ rl.mu.Unlock()
+ }
+}
+
+// Middleware returns a Gin middleware handler
+func (rl *RateLimiter) Middleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ip := c.ClientIP()
+ client := rl.getClient(ip)
+
+ rl.mu.Lock()
+ rl.refillTokens(client)
+
+ if client.tokens >= rl.tokensPerHit {
+ client.tokens -= rl.tokensPerHit
+ rl.mu.Unlock()
+ c.Next()
+ } else {
+ rl.mu.Unlock()
+ c.JSON(http.StatusTooManyRequests, gin.H{
+ "error": "Rate limit exceeded. Please try again later.",
+ })
+ c.Abort()
+ }
+ }
+}
+
+// Stop stops the cleanup ticker
+func (rl *RateLimiter) Stop() {
+ if rl.cleanupTicker != nil {
+ rl.cleanupTicker.Stop()
+ }
+}
diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh
new file mode 100755
index 0000000..b9a263a
--- /dev/null
+++ b/scripts/build-deb.sh
@@ -0,0 +1,152 @@
+#!/bin/bash
+# build-deb.sh - Build Debian package for FeatherDDNS (runs inside container)
+set -e
+
+VERSION="${VERSION:-1.0.0}"
+ARCH="amd64"
+PACKAGE_NAME="featherddns"
+MAINTAINER="Steven Tracey "
+HOMEPAGE="https://git.nevets.tech/Steven/FeatherDDNS"
+
+BUILD_DIR="build/${PACKAGE_NAME}_${VERSION}_${ARCH}"
+
+echo "Building ${PACKAGE_NAME} version ${VERSION}..."
+
+# Clean and create build directory
+rm -rf "$BUILD_DIR"
+mkdir -p "$BUILD_DIR/DEBIAN"
+mkdir -p "$BUILD_DIR/usr/bin"
+mkdir -p "$BUILD_DIR/etc/featherddns"
+mkdir -p "$BUILD_DIR/lib/systemd/system"
+
+# Copy and strip binary
+echo "Copying binary..."
+cp "bin/FeatherDDNS" "$BUILD_DIR/usr/bin/featherddns"
+strip "$BUILD_DIR/usr/bin/featherddns"
+chmod 755 "$BUILD_DIR/usr/bin/featherddns"
+
+# Copy config if exists
+if [ -f "config.json.example" ]; then
+ echo "Copying configuration..."
+ cp "config.json.example" "$BUILD_DIR/etc/featherddns/config.json"
+ chmod 644 "$BUILD_DIR/etc/featherddns/config.json"
+fi
+
+# Copy systemd service if exists
+if [ -f "featherddns.service" ]; then
+ echo "Copying systemd service..."
+ cp "featherddns.service" "$BUILD_DIR/lib/systemd/system/"
+ chmod 644 "$BUILD_DIR/lib/systemd/system/featherddns.service"
+fi
+
+# Create control file
+cat > "$BUILD_DIR/DEBIAN/control" < "$BUILD_DIR/DEBIAN/conffiles" < "$BUILD_DIR/DEBIAN/postinst" <<'EOF'
+#!/bin/bash
+set -e
+
+case "$1" in
+ configure)
+ # Create featherddns user if it doesn't exist
+ if ! getent passwd featherddns > /dev/null; then
+ useradd --system --home-dir /var/lib/featherddns --shell /bin/false featherddns
+ fi
+
+ # Create directories
+ mkdir -p /var/lib/featherddns
+ mkdir -p /var/log/featherddns
+ chown featherddns:featherddns /var/lib/featherddns
+ chown featherddns:featherddns /var/log/featherddns
+
+ # Reload systemd
+ if [ -d /run/systemd/system ]; then
+ systemctl daemon-reload >/dev/null || true
+ fi
+ ;;
+esac
+
+exit 0
+EOF
+chmod 755 "$BUILD_DIR/DEBIAN/postinst"
+
+# Create prerm script
+cat > "$BUILD_DIR/DEBIAN/prerm" <<'EOF'
+#!/bin/bash
+set -e
+
+case "$1" in
+ remove|upgrade)
+ if [ -d /run/systemd/system ]; then
+ systemctl stop featherddns.service >/dev/null 2>&1 || true
+ fi
+ ;;
+esac
+
+exit 0
+EOF
+chmod 755 "$BUILD_DIR/DEBIAN/prerm"
+
+# Create postrm script
+cat > "$BUILD_DIR/DEBIAN/postrm" <<'EOF'
+#!/bin/bash
+set -e
+
+case "$1" in
+ remove)
+ if [ -d /run/systemd/system ]; then
+ systemctl daemon-reload >/dev/null || true
+ fi
+ ;;
+ purge)
+ rm -rf /etc/featherddns
+ rm -rf /var/lib/featherddns
+ rm -rf /var/log/featherddns
+ if [ -d /run/systemd/system ]; then
+ systemctl daemon-reload >/dev/null || true
+ fi
+ ;;
+esac
+
+exit 0
+EOF
+chmod 755 "$BUILD_DIR/DEBIAN/postrm"
+
+# Build the package
+echo "Building package..."
+dpkg-deb --build --root-owner-group "$BUILD_DIR"
+
+# Move to dist directory
+mkdir -p dist
+mv "${BUILD_DIR}.deb" "dist/"
+
+echo "Package built: dist/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
+
+# Run lintian check
+if command -v lintian > /dev/null 2>&1; then
+ echo "Running lintian check..."
+ lintian "dist/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb" || true
+fi
+
+echo "Done!"
diff --git a/scripts/package.sh b/scripts/package.sh
new file mode 100755
index 0000000..b86ba68
--- /dev/null
+++ b/scripts/package.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+# package.sh - Build Debian package for FeatherDDNS using a Debian container
+#
+# Usage: ./scripts/package.sh [version]
+#
+# Environment Variables:
+# CONTAINER_RUNTIME Container runtime to use (default: podman)
+# VERSION Override package version
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+IMAGE_NAME="featherddns-debian-builder"
+CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-podman}"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+info() { echo -e "${BLUE}[INFO]${NC} $1"; }
+success() { echo -e "${GREEN}[OK]${NC} $1"; }
+error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
+
+# Get version from Makefile or argument
+get_version() {
+ grep "^VERSION" "$PROJECT_ROOT/Makefile" 2>/dev/null | head -1 | sed 's/.*:= *//' | tr -d ' '
+}
+
+VERSION="${1:-${VERSION:-$(get_version)}}"
+VERSION="${VERSION:-1.0.0}"
+
+info "Packaging FeatherDDNS version ${VERSION}"
+
+# Check container runtime
+if ! command -v "$CONTAINER_RUNTIME" >/dev/null 2>&1; then
+ error "Container runtime '$CONTAINER_RUNTIME' not found"
+ info "Install podman or docker, or set CONTAINER_RUNTIME"
+ exit 1
+fi
+
+info "Using container runtime: $CONTAINER_RUNTIME"
+
+# Check binary exists
+if [[ ! -f "$PROJECT_ROOT/bin/FeatherDDNS" ]]; then
+ error "Binary not found: bin/FeatherDDNS"
+ info "Build first with: make build"
+ exit 1
+fi
+
+success "Binary found: bin/FeatherDDNS"
+
+# Build container image if needed
+info "Checking for debian-builder image..."
+
+NEED_BUILD=false
+if ! $CONTAINER_RUNTIME image exists "$IMAGE_NAME" 2>/dev/null; then
+ info "Image does not exist, building..."
+ NEED_BUILD=true
+else
+ DOCKERFILE_TIME=$(stat -c %Y "$PROJECT_ROOT/Dockerfile.debian-builder" 2>/dev/null || echo 0)
+ IMAGE_TIME=$($CONTAINER_RUNTIME image inspect "$IMAGE_NAME" --format '{{.Created}}' 2>/dev/null | xargs -I{} date -d {} +%s 2>/dev/null || echo 0)
+ if [[ "$DOCKERFILE_TIME" -gt "$IMAGE_TIME" ]]; then
+ info "Dockerfile is newer than image, rebuilding..."
+ NEED_BUILD=true
+ fi
+fi
+
+if [[ "$NEED_BUILD" == "true" ]]; then
+ if ! $CONTAINER_RUNTIME build -t "$IMAGE_NAME" -f "$PROJECT_ROOT/Dockerfile.debian-builder" "$PROJECT_ROOT"; then
+ error "Failed to build debian-builder image"
+ exit 1
+ fi
+ success "debian-builder image built"
+else
+ success "debian-builder image is up-to-date"
+fi
+
+# Create dist directory
+mkdir -p "$PROJECT_ROOT/dist"
+
+# Determine SELinux mount option
+MOUNT_OPT=""
+if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce 2>/dev/null)" != "Disabled" ]]; then
+ MOUNT_OPT=":Z"
+ info "SELinux detected, using :Z mount flag"
+fi
+
+# Run packaging in container
+info "Running packaging in container..."
+
+if ! $CONTAINER_RUNTIME run --rm \
+ -v "${PROJECT_ROOT}:/build${MOUNT_OPT}" \
+ -e "VERSION=${VERSION}" \
+ -w /build \
+ "$IMAGE_NAME" \
+ /bin/bash -c "./scripts/build-deb.sh"; then
+ error "Container packaging failed"
+ exit 1
+fi
+
+# Verify output
+DEB_FILE=$(find "$PROJECT_ROOT/dist" -name "featherddns_*.deb" -type f 2>/dev/null | head -1)
+
+if [[ -n "$DEB_FILE" ]]; then
+ success "Package created: $DEB_FILE"
+ ls -lh "$DEB_FILE"
+else
+ error "Package not found in dist/"
+ ls -la "$PROJECT_ROOT/dist/" 2>/dev/null || true
+ exit 1
+fi
+
+success "Packaging complete!"
diff --git a/scripts/publish-to-gitea.sh b/scripts/publish-to-gitea.sh
new file mode 100755
index 0000000..5769d8f
--- /dev/null
+++ b/scripts/publish-to-gitea.sh
@@ -0,0 +1,150 @@
+#!/bin/bash
+# Upload Debian packages to Gitea APT registry
+set -e
+
+GITEA_URL="${GITEA_URL:-https://git.nevets.tech}"
+GITEA_OWNER="${GITEA_OWNER:-steven}"
+GITEA_TOKEN="${GITEA_TOKEN}"
+DIST_DIR="${DIST_DIR:-dist}"
+DISTRIBUTION="${DISTRIBUTION:-trixie}" # Debian 13
+COMPONENT="${COMPONENT:-main}"
+PACKAGE_FILTER="${PACKAGE_FILTER:-}" # Optional filter for specific package
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+if [ -z "$GITEA_TOKEN" ]; then
+ echo -e "${RED}Error: GITEA_TOKEN environment variable not set${NC}"
+ echo "Set it with: export GITEA_TOKEN='your-token-here'"
+ echo ""
+ echo "The token must have the following permissions:"
+ echo " - write:package (to upload packages)"
+ echo " - read:package (to read package metadata)"
+ echo ""
+ echo "Create a token at: ${GITEA_URL}/user/settings/applications"
+ exit 1
+fi
+
+if [ ! -d "$DIST_DIR" ]; then
+ echo -e "${RED}Error: Distribution directory $DIST_DIR not found${NC}"
+ echo "Build packages first with: make package"
+ exit 1
+fi
+
+echo -e "${BLUE}Publishing packages to Gitea APT registry...${NC}"
+echo "Repository: ${GITEA_URL}/api/packages/${GITEA_OWNER}/debian"
+echo "Distribution: ${DISTRIBUTION}"
+echo "Component: ${COMPONENT}"
+if [ -n "$PACKAGE_FILTER" ]; then
+ echo "Filter: ${PACKAGE_FILTER}"
+fi
+echo ""
+
+# Verify token has package access
+echo -e "${BLUE}Verifying token permissions...${NC}"
+VERIFY_CODE=$(curl -s -w "%{http_code}" -o /tmp/gitea_verify.txt \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ "${GITEA_URL}/api/v1/user")
+
+if [ "$VERIFY_CODE" -eq 200 ]; then
+ USER=$(grep -o '"login":"[^"]*"' /tmp/gitea_verify.txt | cut -d'"' -f4)
+ echo -e "${GREEN}✓ Token authenticated as user: ${USER}${NC}"
+else
+ echo -e "${RED}✗ Token verification failed (HTTP ${VERIFY_CODE})${NC}"
+ cat /tmp/gitea_verify.txt
+ echo ""
+ echo "Please check your GITEA_TOKEN and try again."
+ exit 1
+fi
+rm -f /tmp/gitea_verify.txt
+echo ""
+
+SUCCESS_COUNT=0
+FAIL_COUNT=0
+
+for deb in "$DIST_DIR"/*.deb; do
+ if [ -f "$deb" ]; then
+ PACKAGE_NAME=$(basename "$deb")
+
+ # Skip if filter is set and package doesn't match
+ if [ -n "$PACKAGE_FILTER" ]; then
+ if [[ ! "$PACKAGE_NAME" =~ ^${PACKAGE_FILTER}_ ]]; then
+ echo -e "${BLUE}Skipping ${PACKAGE_NAME} (doesn't match filter)${NC}"
+ continue
+ fi
+ fi
+
+ echo -e "${BLUE}Uploading ${PACKAGE_NAME}...${NC}"
+
+ # Try uploading with token authentication
+ HTTP_CODE=$(curl -X PUT \
+ -w "%{http_code}" \
+ -o /tmp/gitea_upload_response.txt \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary @"${deb}" \
+ "${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${DISTRIBUTION}/${COMPONENT}/upload")
+
+ if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
+ echo -e "${GREEN}✓ Uploaded ${PACKAGE_NAME}${NC}"
+ SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
+ else
+ echo -e "${RED}✗ Failed to upload ${PACKAGE_NAME} (HTTP ${HTTP_CODE})${NC}"
+ echo "Response:"
+ cat /tmp/gitea_upload_response.txt
+ echo ""
+
+ # Provide troubleshooting guidance for common errors
+ if [ "$HTTP_CODE" -eq 401 ]; then
+ echo -e "${RED}Authentication failed. Please check:${NC}"
+ echo " 1. Token is valid and not expired"
+ echo " 2. Token has 'write:package' permission"
+ echo " 3. You have access to the '${GITEA_OWNER}' organization"
+ echo " Create/update token at: ${GITEA_URL}/user/settings/applications"
+ elif [ "$HTTP_CODE" -eq 403 ]; then
+ echo -e "${RED}Permission denied. Ensure the token has package write access.${NC}"
+ elif [ "$HTTP_CODE" -eq 404 ]; then
+ echo -e "${RED}Repository not found. Verify the owner '${GITEA_OWNER}' exists.${NC}"
+ fi
+ echo ""
+ FAIL_COUNT=$((FAIL_COUNT + 1))
+ fi
+ fi
+done
+
+echo ""
+echo -e "${BLUE}Upload Summary:${NC}"
+echo -e "${GREEN}Successful: ${SUCCESS_COUNT}${NC}"
+if [ "$FAIL_COUNT" -gt 0 ]; then
+ echo -e "${RED}Failed: ${FAIL_COUNT}${NC}"
+fi
+echo ""
+
+if [ "$SUCCESS_COUNT" -gt 0 ]; then
+ echo -e "${GREEN}Repository setup instructions:${NC}"
+ echo ""
+ echo " # Add GPG key"
+ echo " sudo curl ${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/repository.key -o /etc/apt/keyrings/gitea-${GITEA_OWNER}.asc"
+ echo ""
+ echo " # Add repository"
+ echo " echo \"deb [signed-by=/etc/apt/keyrings/gitea-${GITEA_OWNER}.asc] ${GITEA_URL}/api/packages/${GITEA_OWNER}/debian ${DISTRIBUTION} ${COMPONENT}\" | sudo tee -a /etc/apt/sources.list.d/gitea-${GITEA_OWNER}.list"
+ echo ""
+ echo " # Update package list"
+ echo " sudo apt update"
+ echo ""
+ echo " # Install packages"
+ echo " sudo apt install featherddns"
+ echo ""
+fi
+
+# Cleanup
+rm -f /tmp/gitea_upload_response.txt
+
+if [ "$FAIL_COUNT" -gt 0 ]; then
+ exit 1
+fi
+
+exit 0