Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion cmd/wfctl/secrets_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"regexp"
"strings"

"github.com/GoCodeAlone/workflow/config"
Expand Down Expand Up @@ -151,8 +152,25 @@ func runSecretsSetWithReader(args []string, r io.Reader) error {
fromFile := fs.String("from-file", "", "Read secret value from file (for certs/keys)")
providerName := fs.String("provider", "", "Ad-hoc provider override (keychain|env|aws); bypasses app.yaml")
service := fs.String("service", "", "Service name for keychain provider")
scope := fs.String("scope", "", "GitHub secret scope: repo (default) | env | org")
envName := fs.String("env", "", "GitHub Actions environment name (required with --scope=env)")
org := fs.String("org", "", "GitHub org name (required with --scope=org)")
orgVisibility := fs.String("visibility", "all", "Org-scope visibility: all | selected | private")
tokenEnv := fs.String("token-env", "GITHUB_TOKEN", "Env var holding the GitHub PAT")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: wfctl secrets set <name> [options]\n\nSet a secret value in the provider.\n\nOptions:\n")
fmt.Fprintf(fs.Output(), `Usage: wfctl secrets set <name> [options]

Set a secret value in the configured provider.

Scope flags (GitHub only):
--scope repo Default. Writes to the configured app.yaml repo provider.
--scope env --env <name>
Writes to the repo-environment of the same repo.
--scope org --org <slug> [--visibility all|selected|private] [--token-env <var>]
Writes an org-level secret. Requires admin:org token scope.

Options:
`)
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
Expand Down Expand Up @@ -210,6 +228,50 @@ func runSecretsSetWithReader(args []string, r io.Reader) error {
return nil
}

// Org-scope: build an org GH provider directly. Bypasses app.yaml
// since org secrets are out-of-band of the repo-scoped config.
if *scope == "org" {
if *org == "" {
return fmt.Errorf("--scope=org requires --org <slug>")
}
vis, err := parseGitHubOrgVisibility(*orgVisibility)
if err != nil {
return err
}
p, err := secrets.NewGitHubOrgSecretsProvider(*org, *tokenEnv, vis, nil)
if err != nil {
return err
}
if err := p.Set(context.Background(), name, secretValue); err != nil {
return fmt.Errorf("set org secret %s: %w", name, err)
}
fmt.Printf("set %s (org=%s, visibility=%s)\n", name, *org, *orgVisibility)
return nil
}

// Env-scope: build a repo-scoped GH provider, then flip into env
// mode. Requires the repo to be derived from --config app.yaml's
// secret block (provider=github + config.repo).
if *scope == "env" {
if *envName == "" {
return fmt.Errorf("--scope=env requires --env <environment-name>")
}
repo, err := readGitHubRepoFromAppYAML(*configFile)
if err != nil {
return err
}
p, err := secrets.NewGitHubSecretsProvider(repo, *tokenEnv)
if err != nil {
return err
}
p.SetEnvironment(*envName)
if err := p.Set(context.Background(), name, secretValue); err != nil {
return fmt.Errorf("set env secret %s: %w", name, err)
}
fmt.Printf("set %s (env=%s)\n", name, *envName)
return nil
}

// Default path: load provider from app.yaml secrets block.
cfg, err := loadSecretsConfig(*configFile)
if err != nil {
Expand All @@ -226,6 +288,37 @@ func runSecretsSetWithReader(args []string, r io.Reader) error {
return nil
}

// readGitHubRepoFromAppYAML loads app.yaml and returns the configured
// github repo from secrets.config.repo (or secrets.secretStores.<name>.config.repo).
func readGitHubRepoFromAppYAML(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
// Lightweight regexp scan — avoids full YAML round-trip and tolerates
// either `secrets.config.repo` or `secretStores.<name>.config.repo`.
re := regexp.MustCompile(`(?m)^\s*repo:\s*([^\s#]+)`)
m := re.FindStringSubmatch(string(data))
if len(m) < 2 {
return "", fmt.Errorf("could not find `repo:` in %s (expected secrets.config.repo or secretStores.<name>.config.repo)", path)
}
return strings.Trim(m[1], `"'`), nil
}

// parseGitHubOrgVisibility canonicalises the --visibility flag.
func parseGitHubOrgVisibility(s string) (secrets.GitHubOrgVisibility, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "all":
return secrets.OrgVisibilityAll, nil
case "selected":
return secrets.OrgVisibilitySelected, nil
case "private":
return secrets.OrgVisibilityPrivate, nil
default:
return "", fmt.Errorf("invalid visibility %q (must be all|selected|private)", s)
}
}

func stdinFileDescriptor() (int, error) {
fd := os.Stdin.Fd()
maxInt := int(^uint(0) >> 1)
Expand Down
111 changes: 111 additions & 0 deletions docs/plans/2026-05-20-dns-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# DNS providers + DynDNS + scoped secret-set

Caveman SPEC. See `FORMAT.md` for grammar.

## §G — Goal

Extend wfctl IaC surface for ∀ DNS provider, ∀ secret scope. Namecheap +
Hover + DynDNS shipped. Hover login: user+pw+TOTP. Plugin
declares required secrets → wfctl prompts → writes to scoped GH
target (org|repo|env).

## §C — Constraints

```
C1: DNS provider plugin ! implements infra.dns resource type via existing iac.ResourceDriver shape (DO precedent: workflow-plugin-digitalocean/internal/drivers/dns.go)
C2: Namecheap client = github.com/namecheap/go-namecheap-sdk v1.7+
C3: Hover ⊥ official SDK ∴ scraper-style HTTPS client w/ cookie jar (mirror github.com/pjslauta/hover-dyn-dns: POST https://www.hover.com/signin → TOTP challenge → cookie session → DNS CRUD via internal API)
C4: TOTP RFC 6238; Hover seed stored as base32-encoded HOVER_TOTP_SECRET; wfctl never logs the seed
C5: wfctl secrets set --scope ∈ {repo, env, org} ; default = repo (backwards-compat)
C6: GH org secrets ! visibility config (all | selected_repos | private_repos)
C7: GH env secrets ! environment_name flag
C8: wfctl secrets setup --plugin <name> reads plugin manifest required_secrets[] → interactive prompt each → write to chosen scope
C9: infra.dyndns module: poll IP → diff vs current A record → update via DNS driver Update RPC
C10: dyndns ! polling cadence default = 5m; configurable
C11: dyndns IP-detect sources: icanhazip | ifconfig.me | opendns ; multiple sources for redundancy
C12: TOTP code generation in-process; ⊥ external `oathtool` dep
C13: Namecheap auth = (api_user, api_key, client_ip allowlist); wfctl secrets setup writes api_user + api_key
C14: Hover scraper resilient to login-page CSRF token rotation (parse `<input name="_token" value="...">` each login)
C15: DynDNS state machine: detect → diff → update → wait → repeat; on err exponential backoff w/ jitter (max 1h)
C16: ∀ DNS plugin ! pass strict-contracts gRPC boundary (typed proto, no map[string]any)
```

## §I — Interfaces

```
api: GET https://api.namecheap.com/xml.response?Command=namecheap.domains.dns.getHosts → DomainDNSGetHostsResponse XML
api: POST https://api.namecheap.com/xml.response Command=namecheap.domains.dns.setHosts → DomainDNSSetHostsResponse
api: POST https://www.hover.com/signin form: {username, password, _token} → 302 redirect (TOTP page)
api: POST https://www.hover.com/signin/totp form: {code, _token} → 302 (session cookie)
api: GET https://www.hover.com/api/domains/<domain>/dns → JSON {domains: [...]}
api: POST https://www.hover.com/api/dns form: {domain_id, name, type, content, ttl}
api: PUT https://www.hover.com/api/dns/<record_id> form: {content, ttl}
api: DELETE https://www.hover.com/api/dns/<record_id>
cmd: `wfctl secrets set <name> --scope <repo|env|org> [--env <env>] [--visibility <all|selected|private>]`
cmd: `wfctl secrets setup --plugin <plugin-name> [--scope <repo|env|org>]`
cmd: `wfctl secrets setup --provider <namecheap|hover|...>` (alias above)
Comment on lines +44 to +46
env: NAMECHEAP_API_USER ! set if iac.dns provider=namecheap
env: NAMECHEAP_API_KEY ! set (sensitive)
env: NAMECHEAP_CLIENT_IP ! set ; whitelisted at api.namecheap.com
env: HOVER_USERNAME ! set if iac.dns provider=hover
env: HOVER_PASSWORD ! set (sensitive)
env: HOVER_TOTP_SECRET ! set (sensitive; base32 seed)
manifest: plugin.json required_secrets[] = [{name, sensitive, description, prompt}]
```
Comment on lines +50 to +54

## §V — Invariants

```
V1: ∀ secret write via wfctl ! mask in stdout/stderr
V2: Hover login ! happens iff session cookie expired || ⊥
V3: TOTP code ! regenerated on each login attempt (never cached)
V4: scope=org ! requires admin:org GH PAT scope
V5: scope=env ! requires repo + workflow GH PAT scope
V6: scope=repo (default) ! requires repo GH PAT scope
V7: required_secrets prompt ! masked input via term.ReadPassword when sensitive=true
V8: dyndns ! avoid update RPC if detected IP == current record IP
V9: dyndns ! exponential backoff on consecutive failures; max 1h; reset on success
V10: Hover cookie jar ! persisted across plugin restarts via /var/lib/wfctl/hover-session.json (mode 0600); ⊥ committed
V11: Namecheap client_ip allowlist ! validated against ipify.org on plugin start; warn if mismatch
V12: ∀ DNS provider plugin ! emit `infra.dns` resource shape: {ID, Type:"infra.dns", Outputs:{provider_id, records:[]}}
V13: dyndns module ! emit metrics (gauge dyndns_last_detected_ip, counter dyndns_updates_total{provider})
V14: TOTP seed ! base32-decoded once on plugin Init; ⊥ logged
V15: Hover scraper ! User-Agent header set; otherwise hover may return CAPTCHA
V16: GH org secret ! created via PUT /orgs/{org}/actions/secrets/{name}; encrypted with org public key
V17: GH env secret ! created via PUT /repos/{owner}/{repo}/environments/{env}/secrets/{name}; encrypted with env public key
V18: wfctl secrets set --scope ! short-circuit list-by-name BEFORE create (mirrors DO-Spaces orphan fix patterns from workflow#732)
```

## §T — Tasks

```
id|status|task|cites
T1|.|workflow: extend secrets.GitHubSecretsProvider w/ scope (repo|env|org) constructor + Put switch on scope|C5,C6,C7,V4,V5,V6,V16,V17
T2|.|wfctl secrets set --scope flag + delegation to scoped provider; default repo|C5,V18
Comment on lines +83 to +84
T3|.|wfctl secrets setup --plugin <name>: read plugin.json required_secrets[], prompt each (sensitive=masked), write to scope|C8,V1,V7
T4|.|wfctl secrets setup --provider <name>: alias for --plugin (UX sugar)|C8
T5|.|workflow-plugin-namecheap scaffold (new repo): plugin.json + cmd/ + go.mod + GoReleaser + CI|C2,C13,C16
T6|.|namecheap DNSDriver implements interfaces.ResourceDriver for `infra.dns` (Create/Read/Update/Delete/Diff)|C1,C2,V12
T7|.|namecheap required_secrets manifest entry: NAMECHEAP_API_USER, NAMECHEAP_API_KEY, NAMECHEAP_CLIENT_IP|C13
T8|.|namecheap client_ip validation against ipify.org on plugin Start|V11
T9|.|workflow-plugin-hover scaffold (new repo): plugin.json + cmd/ + go.mod + CI|C3,C16
T10|.|hover HTTPS scraper client: login(user, pw, totp) → session cookie jar; List/Get/Create/Update/Delete record|C3,C14,V2,V15
T11|.|hover required_secrets manifest entry: HOVER_USERNAME, HOVER_PASSWORD, HOVER_TOTP_SECRET (sensitive)|C8
T12|.|in-process TOTP impl (RFC 6238 HMAC-SHA1, 30s window, 6 digit) — pure go, ⊥ deps|C4,C12,V3,V14
T13|.|hover session persistence: /var/lib/wfctl/hover-session.json mode 0600; refresh on 401|V10
T14|.|workflow: infra.dyndns module type — config{provider, domain, record_name, poll_interval, detect_via}|C9,C10,C11
T15|.|dyndns module: polling loop, IP detect (multi-source quorum), diff, Update RPC, backoff|C15,V8,V9
T16|.|dyndns metrics: gauge dyndns_last_detected_ip{provider,record}, counter dyndns_updates_total|V13
T17|.|registry manifests: workflow-plugin-namecheap + workflow-plugin-hover added to workflow-registry|workflow#714
T18|.|scenarios: 70-iac-namecheap-dns + 71-iac-hover-dns + 72-iac-dyndns-multiprovider|C1,C3,C9
T19|.|docs: docs/wfctl-secrets-scopes.md w/ examples; docs/iac-dns-providers.md w/ matrix|C5,C8
T20|.|integration test matrix: GH stub server validates org/env/repo PUT paths; secrets set roundtrip|T1,T2
```

## §B — Bugs

```
id|date|cause|fix
```

(empty)
110 changes: 97 additions & 13 deletions secrets/github_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,48 @@ import (

const githubAPIBase = "https://api.github.com"

// GitHubSecretsProvider manages GitHub Actions repository secrets.
// Secrets are write-only on GitHub, so Get() returns ErrUnsupported.
// GitHubSecretScope selects which GitHub secret namespace a provider
// writes to. Default zero value = repo (backwards-compat).
//
// GitHubScopeRepo → /repos/{owner}/{repo}/actions/secrets/...
// GitHubScopeEnv → /repos/{owner}/{repo}/environments/{env}/secrets/...
// GitHubScopeOrg → /orgs/{org}/actions/secrets/...
type GitHubSecretScope string

const (
GitHubScopeRepo GitHubSecretScope = "repo"
GitHubScopeEnv GitHubSecretScope = "env"
GitHubScopeOrg GitHubSecretScope = "org"
)

// GitHubOrgVisibility controls who can pull an org-scoped secret. Mirrors
// GitHub's API field; one of "all", "selected", "private".
type GitHubOrgVisibility string

const (
OrgVisibilityAll GitHubOrgVisibility = "all"
OrgVisibilitySelected GitHubOrgVisibility = "selected"
OrgVisibilityPrivate GitHubOrgVisibility = "private"
)

// GitHubSecretsProvider manages GitHub Actions secrets at repo, env, or
// org scope. Secrets are write-only on GitHub, so Get() returns
// ErrUnsupported.
type GitHubSecretsProvider struct {
owner string
repo string
env string
token string
client *http.Client
scope GitHubSecretScope
owner string // for repo/env scope
repo string // for repo/env scope
env string // for env scope
org string // for org scope
orgVisibility GitHubOrgVisibility
selectedRepoIDs []int64 // required iff scope=org && visibility=selected
token string
client *http.Client
}

// NewGitHubSecretsProvider creates a provider for the given "owner/repo".
// tokenEnvVar is the name of the environment variable holding the GitHub token.
// NewGitHubSecretsProvider creates a repo-scoped provider for the given
// "owner/repo". tokenEnvVar is the name of the environment variable
// holding the GitHub token. Backwards-compatible — sets scope=repo.
func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsProvider, error) {
parts := strings.SplitN(repo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
Expand All @@ -41,19 +71,63 @@ func NewGitHubSecretsProvider(repo string, tokenEnvVar string) (*GitHubSecretsPr
return nil, fmt.Errorf("secrets: env var %q is empty or unset", tokenEnvVar)
}
return &GitHubSecretsProvider{
scope: GitHubScopeRepo,
owner: parts[0],
repo: parts[1],
token: token,
client: &http.Client{},
}, nil
}

// NewGitHubOrgSecretsProvider creates an org-scoped provider. visibility
// is one of OrgVisibilityAll / Selected / Private. selectedRepoIDs is
// required iff visibility=Selected.
//
// Requires the token to have admin:org scope.
func NewGitHubOrgSecretsProvider(org string, tokenEnvVar string, visibility GitHubOrgVisibility, selectedRepoIDs []int64) (*GitHubSecretsProvider, error) {
if org == "" {
return nil, fmt.Errorf("secrets: github org name is required")
}
token := os.Getenv(tokenEnvVar)
if token == "" {
return nil, fmt.Errorf("secrets: env var %q is empty or unset", tokenEnvVar)
}
if visibility == "" {
visibility = OrgVisibilityAll
}
switch visibility {
case OrgVisibilityAll, OrgVisibilitySelected, OrgVisibilityPrivate:
default:
return nil, fmt.Errorf("secrets: github org visibility must be all|selected|private, got %q", visibility)
}
if visibility == OrgVisibilitySelected && len(selectedRepoIDs) == 0 {
return nil, fmt.Errorf("secrets: github org visibility=selected requires selected_repository_ids")
}
return &GitHubSecretsProvider{
scope: GitHubScopeOrg,
org: org,
orgVisibility: visibility,
selectedRepoIDs: append([]int64(nil), selectedRepoIDs...),
token: token,
client: &http.Client{},
}, nil
}

// Scope reports the current scope.
func (p *GitHubSecretsProvider) Scope() GitHubSecretScope { return p.scope }

func (p *GitHubSecretsProvider) Name() string { return "github" }

// SetEnvironment scopes subsequent operations to a GitHub Actions environment.
// Empty scope means repository-level secrets.
// Empty scope means repository-level secrets. Calling SetEnvironment with a
// non-empty value flips scope to env.
func (p *GitHubSecretsProvider) SetEnvironment(environment string) {
p.env = strings.TrimSpace(environment)
if p.env != "" {
p.scope = GitHubScopeEnv
} else if p.scope == GitHubScopeEnv {
p.scope = GitHubScopeRepo
}
}

// Environment returns the configured GitHub Actions environment scope.
Expand All @@ -80,10 +154,16 @@ func (p *GitHubSecretsProvider) Set(ctx context.Context, key, value string) erro
return fmt.Errorf("secrets: github encrypt: %w", err)
}

payload := map[string]string{
payload := map[string]any{
"encrypted_value": encrypted,
"key_id": pubKeyID,
}
if p.scope == GitHubScopeOrg {
payload["visibility"] = string(p.orgVisibility)
if p.orgVisibility == OrgVisibilitySelected {
payload["selected_repository_ids"] = p.selectedRepoIDs
}
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.secretURL(key), bytes.NewReader(body))
if err != nil {
Expand Down Expand Up @@ -176,10 +256,14 @@ func (p *GitHubSecretsProvider) setHeaders(req *http.Request) {
}

func (p *GitHubSecretsProvider) secretsURL() string {
if p.env != "" {
switch p.scope {
case GitHubScopeOrg:
return fmt.Sprintf("%s/orgs/%s/actions/secrets", githubAPIBase, p.org)
case GitHubScopeEnv:
return fmt.Sprintf("%s/repos/%s/%s/environments/%s/secrets", githubAPIBase, p.owner, p.repo, url.PathEscape(p.env))
default: // GitHubScopeRepo
return fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo)
}
return fmt.Sprintf("%s/repos/%s/%s/actions/secrets", githubAPIBase, p.owner, p.repo)
}

func (p *GitHubSecretsProvider) secretURL(key string) string {
Expand Down
Loading
Loading