Push code

This commit is contained in:
2026-01-28 23:03:50 +01:00
parent 5bd203b516
commit 00bbd6534b
19 changed files with 1224 additions and 0 deletions

4
.gitignore vendored
View File

@@ -115,3 +115,7 @@ fabric.properties
# Android studio 3.1+ serialized cache file # Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
bin/
build/
dist/
config.json

4
.idea/FeatherDDNS.iml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="Go" enabled="true" />
</module>

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>
<option value="X-Source-IP" />
</set>
</option>
</inspection_tool>
</profile>
</component>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

33
Dockerfile.debian-builder Normal file
View File

@@ -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 <steven@rvits.net>"
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"]

26
Makefile Normal file
View File

@@ -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)

79
cache/MemCache.go vendored Normal file
View File

@@ -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)
}

26
config.json.example Normal file
View File

@@ -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"
}
]
}

79
dns/cloudflare.go Normal file
View File

@@ -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
}

18
endpoints.http Normal file
View File

@@ -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=

36
featherddns.service Normal file
View File

@@ -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

47
go.mod Normal file
View File

@@ -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
)

103
go.sum Normal file
View File

@@ -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=

205
main.go Normal file
View File

@@ -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")
}

117
ratelimit/ratelimit.go Normal file
View File

@@ -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()
}
}

152
scripts/build-deb.sh Executable file
View File

@@ -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 <steven@nevets.tech>"
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" <<EOF
Package: ${PACKAGE_NAME}
Version: ${VERSION}
Section: net
Priority: optional
Architecture: ${ARCH}
Maintainer: ${MAINTAINER}
Depends: libc6
Homepage: ${HOMEPAGE}
Description: FeatherDDNS - Lightweight Dynamic DNS Server
A lightweight dynamic DNS server for updating DNS records
via HTTP API requests.
EOF
# Create conffiles if config exists
if [ -f "$BUILD_DIR/etc/featherddns/config.json" ]; then
cat > "$BUILD_DIR/DEBIAN/conffiles" <<EOF
/etc/featherddns/config.json
EOF
chmod 644 "$BUILD_DIR/DEBIAN/conffiles"
fi
# Create postinst script
cat > "$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!"

116
scripts/package.sh Executable file
View File

@@ -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!"

150
scripts/publish-to-gitea.sh Executable file
View File

@@ -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