diff --git a/.env.example b/.env.example index 188501d..fc58775 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/config.go b/config.go index 82c9762..eb9f3f5 100644 --- a/config.go +++ b/config.go @@ -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 @@ -28,9 +31,9 @@ type Config struct { ReconcileInterval time.Duration DebounceDelay time.Duration - RecordTTL int + RecordTTL int CloudflareAutoTTL bool - ExcludeRouters map[string]struct{} + ExcludeRouters map[string]struct{} } // 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.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)") diff --git a/git.go b/git.go index ffd2e53..ad4f63a 100644 --- a/git.go +++ b/git.go @@ -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 diff --git a/main.go b/main.go index 8635232..a0fe7ab 100644 --- a/main.go +++ b/main.go @@ -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()) {