feat: implemented token auth

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

View File

@@ -49,6 +49,17 @@ DNS_REPO_BRANCH=main
# Git remote name.
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.
# The watcher will only write to this directory; static zones are left untouched.
DNS_REPO_DYNAMIC_DIR=zones-dynamic

View File

@@ -14,6 +14,9 @@ type Config struct {
TraefikUsername string
TraefikPassword string
GitAuthUsername string
GitAuthToken string
Zones []string
PublicIP string
PublicIPv6 string // empty = no AAAA records
@@ -41,6 +44,11 @@ func LoadConfig() (*Config, error) {
cfg.TraefikUsername = os.Getenv("TRAEFIK_USERNAME")
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")
if zonesStr == "" {
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 (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// gitRun executes a git command in repoPath, returning a descriptive error
// that includes the combined stdout+stderr on failure.
func gitRun(repoPath string, args ...string) error {
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
func gitRun(cfg *Config, args ...string) error {
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()
if err != nil {
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.
func gitOutput(repoPath string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
func gitOutput(cfg *Config, args ...string) (string, error) {
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()
if err != nil {
var stderr []byte
@@ -34,14 +47,38 @@ func gitOutput(repoPath string, args ...string) (string, error) {
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.
// Using --autostash ensures any uncommitted changes are preserved across the rebase
// (should not normally happen but guards against manual edits).
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)
}
if err := gitRun(cfg.RepoPath,
if err := gitRun(cfg,
"pull", "--rebase", "--autostash",
cfg.RepoRemote, cfg.RepoBranch,
); err != nil {
@@ -52,7 +89,7 @@ func GitPull(cfg *Config) error {
// GitStatusChanged reports true when the working tree has uncommitted changes.
func GitStatusChanged(cfg *Config) (bool, error) {
out, err := gitOutput(cfg.RepoPath, "status", "--porcelain")
out, err := gitOutput(cfg, "status", "--porcelain")
if err != nil {
return false, err
}
@@ -63,17 +100,17 @@ func GitStatusChanged(cfg *Config) (bool, error) {
// and pushes to the configured remote branch.
// Author identity is passed via git -c flags to avoid requiring global git config.
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)
}
if err := gitRun(cfg.RepoPath,
if err := gitRun(cfg,
"-c", "user.name="+cfg.AuthorName,
"-c", "user.email="+cfg.AuthorEmail,
"commit", "-m", message,
); err != nil {
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 nil

31
main.go
View File

@@ -11,12 +11,17 @@ import (
"net/url"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
)
func main() {
if maybeHandleGitAskpass() {
return
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
@@ -32,8 +37,11 @@ func main() {
"zones", cfg.Zones,
"repo_path", cfg.RepoPath,
"dynamic_dir", cfg.DynamicDir,
"git_https_token_enabled", cfg.GitAuthToken != "",
"git_auth_username", cfg.GitAuthUsername,
"reconcile_interval", cfg.ReconcileInterval,
"debounce_delay", cfg.DebounceDelay,
"cf_auto_ttl", cfg.CloudflareAutoTTL,
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
@@ -107,6 +115,29 @@ func main() {
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
// events to the trigger function. Reconnects with exponential backoff on failure.
func watchDockerEvents(ctx context.Context, trigger func()) {