feat: implemented token auth

This commit is contained in:
2026-03-15 21:00:42 +03:00
parent 607235d311
commit 4092081c7e
4 changed files with 99 additions and 12 deletions

View File

@@ -49,6 +49,17 @@ DNS_REPO_BRANCH=main
# Git remote name. # Git remote name.
DNS_REPO_REMOTE=origin DNS_REPO_REMOTE=origin
# Optional HTTPS token auth for git pull/push (when remote URL is https://...)
# If GIT_AUTH_TOKEN is set, watcher enables non-interactive GIT_ASKPASS mode.
# GitHub example:
# GIT_AUTH_USERNAME=x-access-token
# GIT_AUTH_TOKEN=ghp_xxx
# GitLab example:
# GIT_AUTH_USERNAME=oauth2
# GIT_AUTH_TOKEN=glpat-xxx
GIT_AUTH_USERNAME=x-access-token
GIT_AUTH_TOKEN=
# Directory inside the repository where dynamic zone files are stored. # Directory inside the repository where dynamic zone files are stored.
# The watcher will only write to this directory; static zones are left untouched. # The watcher will only write to this directory; static zones are left untouched.
DNS_REPO_DYNAMIC_DIR=zones-dynamic DNS_REPO_DYNAMIC_DIR=zones-dynamic

View File

@@ -14,6 +14,9 @@ type Config struct {
TraefikUsername string TraefikUsername string
TraefikPassword string TraefikPassword string
GitAuthUsername string
GitAuthToken string
Zones []string Zones []string
PublicIP string PublicIP string
PublicIPv6 string // empty = no AAAA records PublicIPv6 string // empty = no AAAA records
@@ -28,9 +31,9 @@ type Config struct {
ReconcileInterval time.Duration ReconcileInterval time.Duration
DebounceDelay time.Duration DebounceDelay time.Duration
RecordTTL int RecordTTL int
CloudflareAutoTTL bool CloudflareAutoTTL bool
ExcludeRouters map[string]struct{} ExcludeRouters map[string]struct{}
} }
// LoadConfig reads configuration from environment variables and validates required fields. // LoadConfig reads configuration from environment variables and validates required fields.
@@ -41,6 +44,11 @@ func LoadConfig() (*Config, error) {
cfg.TraefikUsername = os.Getenv("TRAEFIK_USERNAME") cfg.TraefikUsername = os.Getenv("TRAEFIK_USERNAME")
cfg.TraefikPassword = os.Getenv("TRAEFIK_PASSWORD") cfg.TraefikPassword = os.Getenv("TRAEFIK_PASSWORD")
// Optional HTTPS git authentication credentials.
// If GIT_AUTH_TOKEN is set, git commands will run non-interactively via GIT_ASKPASS.
cfg.GitAuthUsername = envOrDefault("GIT_AUTH_USERNAME", "x-access-token")
cfg.GitAuthToken = os.Getenv("GIT_AUTH_TOKEN")
zonesStr := os.Getenv("DNS_ZONES") zonesStr := os.Getenv("DNS_ZONES")
if zonesStr == "" { if zonesStr == "" {
return nil, fmt.Errorf("DNS_ZONES is required (comma-separated list of zones, e.g. example.com,example.net)") return nil, fmt.Errorf("DNS_ZONES is required (comma-separated list of zones, e.g. example.com,example.net)")

57
git.go
View File

@@ -3,14 +3,21 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strings" "strings"
) )
// gitRun executes a git command in repoPath, returning a descriptive error // gitRun executes a git command in repoPath, returning a descriptive error
// that includes the combined stdout+stderr on failure. // that includes the combined stdout+stderr on failure.
func gitRun(repoPath string, args ...string) error { func gitRun(cfg *Config, args ...string) error {
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) env, err := gitCommandEnv(cfg)
if err != nil {
return err
}
cmd := exec.Command("git", append([]string{"-C", cfg.RepoPath}, args...)...)
cmd.Env = env
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git %s: %w\n%s", return fmt.Errorf("git %s: %w\n%s",
@@ -20,8 +27,14 @@ func gitRun(repoPath string, args ...string) error {
} }
// gitOutput executes a git command and returns its stdout as a string. // gitOutput executes a git command and returns its stdout as a string.
func gitOutput(repoPath string, args ...string) (string, error) { func gitOutput(cfg *Config, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) env, err := gitCommandEnv(cfg)
if err != nil {
return "", err
}
cmd := exec.Command("git", append([]string{"-C", cfg.RepoPath}, args...)...)
cmd.Env = env
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
var stderr []byte var stderr []byte
@@ -34,14 +47,38 @@ func gitOutput(repoPath string, args ...string) (string, error) {
return string(out), nil return string(out), nil
} }
// gitCommandEnv builds the environment for git subprocesses.
// If GIT_AUTH_TOKEN is provided, non-interactive HTTPS auth is enabled via askpass.
func gitCommandEnv(cfg *Config) ([]string, error) {
env := os.Environ()
if cfg.GitAuthToken == "" {
return env, nil
}
exePath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve executable path for GIT_ASKPASS: %w", err)
}
env = append(env,
"GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS_REQUIRE=force",
"GIT_ASKPASS="+exePath,
"TDW_GIT_ASKPASS=1",
"GIT_AUTH_USERNAME="+cfg.GitAuthUsername,
"GIT_AUTH_TOKEN="+cfg.GitAuthToken,
)
return env, nil
}
// GitPull fetches from the remote and rebases the local branch. // GitPull fetches from the remote and rebases the local branch.
// Using --autostash ensures any uncommitted changes are preserved across the rebase // Using --autostash ensures any uncommitted changes are preserved across the rebase
// (should not normally happen but guards against manual edits). // (should not normally happen but guards against manual edits).
func GitPull(cfg *Config) error { func GitPull(cfg *Config) error {
if err := gitRun(cfg.RepoPath, "fetch", "--prune", cfg.RepoRemote); err != nil { if err := gitRun(cfg, "fetch", "--prune", cfg.RepoRemote); err != nil {
return fmt.Errorf("git fetch: %w", err) return fmt.Errorf("git fetch: %w", err)
} }
if err := gitRun(cfg.RepoPath, if err := gitRun(cfg,
"pull", "--rebase", "--autostash", "pull", "--rebase", "--autostash",
cfg.RepoRemote, cfg.RepoBranch, cfg.RepoRemote, cfg.RepoBranch,
); err != nil { ); err != nil {
@@ -52,7 +89,7 @@ func GitPull(cfg *Config) error {
// GitStatusChanged reports true when the working tree has uncommitted changes. // GitStatusChanged reports true when the working tree has uncommitted changes.
func GitStatusChanged(cfg *Config) (bool, error) { func GitStatusChanged(cfg *Config) (bool, error) {
out, err := gitOutput(cfg.RepoPath, "status", "--porcelain") out, err := gitOutput(cfg, "status", "--porcelain")
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -63,17 +100,17 @@ func GitStatusChanged(cfg *Config) (bool, error) {
// and pushes to the configured remote branch. // and pushes to the configured remote branch.
// Author identity is passed via git -c flags to avoid requiring global git config. // Author identity is passed via git -c flags to avoid requiring global git config.
func GitCommitAndPush(cfg *Config, message string) error { func GitCommitAndPush(cfg *Config, message string) error {
if err := gitRun(cfg.RepoPath, "add", cfg.DynamicDir); err != nil { if err := gitRun(cfg, "add", cfg.DynamicDir); err != nil {
return fmt.Errorf("git add: %w", err) return fmt.Errorf("git add: %w", err)
} }
if err := gitRun(cfg.RepoPath, if err := gitRun(cfg,
"-c", "user.name="+cfg.AuthorName, "-c", "user.name="+cfg.AuthorName,
"-c", "user.email="+cfg.AuthorEmail, "-c", "user.email="+cfg.AuthorEmail,
"commit", "-m", message, "commit", "-m", message,
); err != nil { ); err != nil {
return fmt.Errorf("git commit: %w", err) return fmt.Errorf("git commit: %w", err)
} }
if err := gitRun(cfg.RepoPath, "push", cfg.RepoRemote, cfg.RepoBranch); err != nil { if err := gitRun(cfg, "push", cfg.RepoRemote, cfg.RepoBranch); err != nil {
return fmt.Errorf("git push: %w", err) return fmt.Errorf("git push: %w", err)
} }
return nil return nil

31
main.go
View File

@@ -11,12 +11,17 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
) )
func main() { func main() {
if maybeHandleGitAskpass() {
return
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, Level: slog.LevelInfo,
}))) })))
@@ -32,8 +37,11 @@ func main() {
"zones", cfg.Zones, "zones", cfg.Zones,
"repo_path", cfg.RepoPath, "repo_path", cfg.RepoPath,
"dynamic_dir", cfg.DynamicDir, "dynamic_dir", cfg.DynamicDir,
"git_https_token_enabled", cfg.GitAuthToken != "",
"git_auth_username", cfg.GitAuthUsername,
"reconcile_interval", cfg.ReconcileInterval, "reconcile_interval", cfg.ReconcileInterval,
"debounce_delay", cfg.DebounceDelay, "debounce_delay", cfg.DebounceDelay,
"cf_auto_ttl", cfg.CloudflareAutoTTL,
) )
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
@@ -107,6 +115,29 @@ func main() {
slog.Info("traefik-dns-watcher stopped") slog.Info("traefik-dns-watcher stopped")
} }
// maybeHandleGitAskpass serves username/password for git HTTPS auth in non-interactive mode.
// This process mode is only enabled for git child processes that set TDW_GIT_ASKPASS=1.
func maybeHandleGitAskpass() bool {
enabled, _ := strconv.ParseBool(os.Getenv("TDW_GIT_ASKPASS"))
if !enabled {
return false
}
prompt := ""
if len(os.Args) > 1 {
prompt = strings.ToLower(os.Args[1])
}
if strings.Contains(prompt, "username") {
fmt.Fprint(os.Stdout, os.Getenv("GIT_AUTH_USERNAME"))
return true
}
// For password/token prompts, return token by default.
fmt.Fprint(os.Stdout, os.Getenv("GIT_AUTH_TOKEN"))
return true
}
// watchDockerEvents connects to the Docker daemon and forwards container lifecycle // watchDockerEvents connects to the Docker daemon and forwards container lifecycle
// events to the trigger function. Reconnects with exponential backoff on failure. // events to the trigger function. Reconnects with exponential backoff on failure.
func watchDockerEvents(ctx context.Context, trigger func()) { func watchDockerEvents(ctx context.Context, trigger func()) {