From 00bbd6534b9deb71c05c50251bdcf0f2c5ccc20f Mon Sep 17 00:00:00 2001 From: Steven Tracey Date: Wed, 28 Jan 2026 23:03:50 +0100 Subject: [PATCH] Push code --- .gitignore | 4 + .idea/FeatherDDNS.iml | 4 + .idea/go.imports.xml | 11 + .idea/inspectionProfiles/Project_Default.xml | 12 ++ .idea/vcs.xml | 6 + Dockerfile.debian-builder | 33 +++ Makefile | 26 +++ cache/MemCache.go | 79 +++++++ config.json.example | 26 +++ dns/cloudflare.go | 79 +++++++ endpoints.http | 18 ++ featherddns.service | 36 ++++ go.mod | 47 +++++ go.sum | 103 ++++++++++ main.go | 205 +++++++++++++++++++ ratelimit/ratelimit.go | 117 +++++++++++ scripts/build-deb.sh | 152 ++++++++++++++ scripts/package.sh | 116 +++++++++++ scripts/publish-to-gitea.sh | 150 ++++++++++++++ 19 files changed, 1224 insertions(+) create mode 100644 .idea/FeatherDDNS.iml create mode 100644 .idea/go.imports.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile.debian-builder create mode 100644 Makefile create mode 100644 cache/MemCache.go create mode 100644 config.json.example create mode 100644 dns/cloudflare.go create mode 100644 endpoints.http create mode 100644 featherddns.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 ratelimit/ratelimit.go create mode 100755 scripts/build-deb.sh create mode 100755 scripts/package.sh create mode 100755 scripts/publish-to-gitea.sh 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