Push code
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -115,3 +115,7 @@ fabric.properties
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
bin/
|
||||
build/
|
||||
dist/
|
||||
config.json
|
||||
|
||||
4
.idea/FeatherDDNS.iml
generated
Normal file
4
.idea/FeatherDDNS.iml
generated
Normal 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
11
.idea/go.imports.xml
generated
Normal 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>
|
||||
12
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
12
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
33
Dockerfile.debian-builder
Normal 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
26
Makefile
Normal 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
79
cache/MemCache.go
vendored
Normal 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
26
config.json.example
Normal 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
79
dns/cloudflare.go
Normal 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
18
endpoints.http
Normal 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
36
featherddns.service
Normal 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
47
go.mod
Normal 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
103
go.sum
Normal 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
205
main.go
Normal 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
117
ratelimit/ratelimit.go
Normal 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
152
scripts/build-deb.sh
Executable 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
116
scripts/package.sh
Executable 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
150
scripts/publish-to-gitea.sh
Executable 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
|
||||
Reference in New Issue
Block a user