Created
April 2, 2026 15:11
-
-
Save favonia/98a2a710d9ffe60f78014f1c75204f35 to your computer and use it in GitHub Desktop.
A debugging helper that mimics the token-loading logic in cloudflare-ddns 1.16.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "bytes" | |
| "context" | |
| "errors" | |
| "fmt" | |
| "io/fs" | |
| "net/http" | |
| "net/http/httputil" | |
| "os" | |
| pathpkg "path" | |
| "regexp" | |
| "strings" | |
| "time" | |
| "github.com/cloudflare/cloudflare-go" | |
| ) | |
| var oauthBearerRegex = regexp.MustCompile(`^[-a-zA-Z0-9._~+/]+=*$`) | |
| const ( | |
| tokenKey1 = "CLOUDFLARE_API_TOKEN" | |
| tokenKey2 = "CF_API_TOKEN" | |
| tokenFileKey1 = "CLOUDFLARE_API_TOKEN_FILE" | |
| tokenFileKey2 = "CF_API_TOKEN_FILE" | |
| ) | |
| const hintAuthTokenNewPrefix = "Cloudflare is switching to the `CLOUDFLARE_*` prefix for its tools. Use `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_API_TOKEN_FILE` instead of `CF_*` (fully supported until 2.0.0 and then minimally supported until 3.0.0)." | |
| const authVerifyTimeout = time.Second | |
| type debugResult struct { | |
| token string | |
| tokenKey string | |
| ok bool | |
| } | |
| func bulletf(format string, args ...any) { | |
| fmt.Printf("- %s\n", fmt.Sprintf(format, args...)) | |
| } | |
| func subBulletf(format string, args ...any) { | |
| fmt.Printf(" - %s\n", fmt.Sprintf(format, args...)) | |
| } | |
| func headerf(level int, format string, args ...any) { | |
| fmt.Println() | |
| fmt.Printf("%s %s\n", strings.Repeat("#", level), fmt.Sprintf(format, args...)) | |
| fmt.Println() | |
| } | |
| func main() { | |
| headerf(1, "cloudflare-ddns Token Debug Helper") | |
| fmt.Println("This helper mimics the token-loading logic in cloudflare-ddns 1.16.1.") | |
| fmt.Println("After loading the token, it also runs the same Cloudflare preflight verification call.") | |
| fmt.Println() | |
| fmt.Println("Unlike cloudflare-ddns, this helper WILL print your API token directly to the screen.") | |
| fmt.Println("DO NOT post the full log on GitHub. If you do, rotate your API token immediately.") | |
| headerf(2, "Environment Variables") | |
| for _, key := range []string{tokenKey1, tokenKey2, tokenFileKey1, tokenFileKey2, "CF_ACCOUNT_ID"} { | |
| describeEnv(key) | |
| } | |
| result := readAuthTokenDebug() | |
| if !result.ok { | |
| headerf(2, "Final Result") | |
| bulletf("failed to load an API token") | |
| os.Exit(1) | |
| } | |
| headerf(2, "Final Result") | |
| bulletf("loaded token from `%s`", result.tokenKey) | |
| subBulletf("loaded token summary: %s", summarizeSecret(result.token)) | |
| runCloudflarePreflight(result.token) | |
| } | |
| func describeEnv(key string) { | |
| value, ok := os.LookupEnv(key) | |
| if !ok { | |
| bulletf("`%s`: unset", key) | |
| return | |
| } | |
| bulletf("`%s`: set", key) | |
| subBulletf("raw summary: %s", summarizeSecret(value)) | |
| subBulletf("Go-quoted raw value: `%q`", value) | |
| } | |
| func summarizeSecret(value string) string { | |
| return fmt.Sprintf("len=%d quoted=%t oauth-bearer-like=%t", len(value), tokenHasMatchingQuotes(value), oauthBearerRegex.MatchString(value)) | |
| } | |
| func tokenHasMatchingQuotes(token string) bool { | |
| if len(token) < 2 { | |
| return false | |
| } | |
| switch { | |
| case token[0] == '"' && token[len(token)-1] == '"': | |
| return true | |
| case token[0] == '\'' && token[len(token)-1] == '\'': | |
| return true | |
| default: | |
| return false | |
| } | |
| } | |
| func ensureTokenNotQuoted(token, tokenKey string) bool { | |
| bulletf("check for surrounding quotes in `%s`", tokenKey) | |
| if !tokenHasMatchingQuotes(token) { | |
| subBulletf("ok: token does not have matching surrounding quotes") | |
| return true | |
| } | |
| subBulletf("error: the token provided by `%s` appears to include surrounding quotation marks; remove the extra quotes", tokenKey) | |
| return false | |
| } | |
| func readPlainAuthTokensDebug() debugResult { | |
| token1 := os.Getenv(tokenKey1) | |
| token2 := os.Getenv(tokenKey2) | |
| headerf(2, "Plain Token Variables") | |
| bulletf("inspect plain token environment variables") | |
| subBulletf("`%s` summary: %s", tokenKey1, summarizeSecret(token1)) | |
| subBulletf("`%s` summary: %s", tokenKey2, summarizeSecret(token2)) | |
| var token, tokenKey string | |
| switch { | |
| case token1 == "" && token2 == "": | |
| subBulletf("result: neither plain token variable is set") | |
| return debugResult{ok: true} | |
| case token1 != "" && token2 != "" && token1 != token2: | |
| subBulletf("error: the values of `%s` and `%s` do not match; they must specify the same token", tokenKey1, tokenKey2) | |
| return debugResult{ok: false} | |
| case token1 != "": | |
| token, tokenKey = token1, tokenKey1 | |
| subBulletf("selected `%s`", tokenKey1) | |
| case token2 != "": | |
| subBulletf("hint: %s", hintAuthTokenNewPrefix) | |
| token, tokenKey = token2, tokenKey2 | |
| subBulletf("selected `%s`", tokenKey2) | |
| } | |
| if token == "YOUR-CLOUDFLARE-API-TOKEN" { | |
| subBulletf("error: you need to provide a real API token as `%s`", tokenKey) | |
| return debugResult{ok: false} | |
| } | |
| if !ensureTokenNotQuoted(token, tokenKey) { | |
| return debugResult{ok: false} | |
| } | |
| subBulletf("success: selected plain token summary: %s", summarizeSecret(token)) | |
| return debugResult{token: token, tokenKey: tokenKey, ok: true} | |
| } | |
| func processPathDebug(path string) (relPath string, fixedPath string, ok bool) { | |
| bulletf("process path") | |
| subBulletf("raw value: `%q`", path) | |
| if !strings.HasPrefix(path, "/") { | |
| subBulletf("error: the path `%q` is not absolute; to use an absolute path, prefix it with `/`", path) | |
| return "", "/" + path, false | |
| } | |
| cleanPath := pathpkg.Clean(path) | |
| relPath = cleanPath[1:] | |
| subBulletf("clean absolute path: `%q`", cleanPath) | |
| subBulletf("relative path used with `os.DirFS(\"/\")`: `%q`", relPath) | |
| return relPath, cleanPath, true | |
| } | |
| func readFileDebug(path string) (string, bool) { | |
| relPath, fixedPath, ok := processPathDebug(path) | |
| if !ok { | |
| subBulletf("suggested fixed path: `%q`", fixedPath) | |
| return "", false | |
| } | |
| body, err := fs.ReadFile(os.DirFS("/"), relPath) | |
| bulletf("read file") | |
| if err != nil { | |
| subBulletf("error: failed to read `%q`: %v", fixedPath, err) | |
| return "", false | |
| } | |
| trimmed := string(bytes.TrimSpace(body)) | |
| subBulletf("raw file byte length: %d", len(body)) | |
| subBulletf("trimmed token summary: %s", summarizeSecret(trimmed)) | |
| subBulletf("raw bytes Go-quoted: `%q`", string(body)) | |
| subBulletf("trimmed bytes Go-quoted: `%q`", trimmed) | |
| return trimmed, true | |
| } | |
| func readAuthTokenFileDebug(key string) debugResult { | |
| tokenFile := os.Getenv(key) | |
| headerf(2, "Token File Variable `%s`", key) | |
| bulletf("inspect `%s`", key) | |
| if tokenFile == "" { | |
| subBulletf("variable unset or empty") | |
| return debugResult{ok: true} | |
| } | |
| subBulletf("path summary: %s", summarizeSecret(tokenFile)) | |
| token, ok := readFileDebug(tokenFile) | |
| if !ok { | |
| return debugResult{ok: false} | |
| } | |
| if token == "" { | |
| subBulletf("error: the file specified by `%s` does not contain an API token", key) | |
| return debugResult{ok: false} | |
| } | |
| if !ensureTokenNotQuoted(token, key) { | |
| return debugResult{ok: false} | |
| } | |
| subBulletf("success: selected file token summary: %s", summarizeSecret(token)) | |
| return debugResult{token: token, tokenKey: key, ok: true} | |
| } | |
| func readAuthTokenFilesDebug() debugResult { | |
| token1 := readAuthTokenFileDebug(tokenFileKey1) | |
| if !token1.ok { | |
| return debugResult{ok: false} | |
| } | |
| token2 := readAuthTokenFileDebug(tokenFileKey2) | |
| if !token2.ok { | |
| return debugResult{ok: false} | |
| } | |
| headerf(2, "Token File Sources") | |
| bulletf("combine file token sources") | |
| switch { | |
| case token1.token != "" && token2.token != "" && token1.token != token2.token: | |
| subBulletf("error: the files specified by `%s` and `%s` have conflicting tokens; their content must match", tokenFileKey1, tokenFileKey2) | |
| return debugResult{ok: false} | |
| case token1.token != "": | |
| subBulletf("selected `%s`", tokenFileKey1) | |
| return token1 | |
| case token2.token != "": | |
| subBulletf("hint: %s", hintAuthTokenNewPrefix) | |
| subBulletf("selected `%s`", tokenFileKey2) | |
| return token2 | |
| default: | |
| subBulletf("result: neither file token variable is set") | |
| return debugResult{ok: true} | |
| } | |
| } | |
| func readAuthTokenDebug() debugResult { | |
| plain := readPlainAuthTokensDebug() | |
| if !plain.ok { | |
| return debugResult{ok: false} | |
| } | |
| file := readAuthTokenFilesDebug() | |
| if !file.ok { | |
| return debugResult{ok: false} | |
| } | |
| headerf(2, "Combined Token Sources") | |
| bulletf("combine plain and file token sources") | |
| var token, tokenKey string | |
| switch { | |
| case plain.token != "" && file.token != "" && plain.token != file.token: | |
| subBulletf("error: the value of `%s` does not match the token found in the file specified by `%s`; they must specify the same token", plain.tokenKey, file.tokenKey) | |
| return debugResult{ok: false} | |
| case plain.token != "": | |
| token, tokenKey = plain.token, plain.tokenKey | |
| subBulletf("selected plain token from `%s`", tokenKey) | |
| case file.token != "": | |
| token, tokenKey = file.token, file.tokenKey | |
| subBulletf("selected file token from `%s`", tokenKey) | |
| default: | |
| subBulletf("error: requires either `%s` or `%s`", tokenKey1, tokenFileKey1) | |
| return debugResult{ok: false} | |
| } | |
| if !oauthBearerRegex.MatchString(token) { | |
| subBulletf("warning: the API token appears to be invalid; it does not follow the OAuth2 bearer token format") | |
| } else { | |
| subBulletf("token matches the OAuth2 bearer token format check") | |
| } | |
| if accountID := os.Getenv("CF_ACCOUNT_ID"); accountID != "" { | |
| subBulletf("warning: `CF_ACCOUNT_ID` is ignored since 1.14.0; summary: %s", summarizeSecret(accountID)) | |
| } | |
| return debugResult{token: token, tokenKey: tokenKey, ok: true} | |
| } | |
| func runCloudflarePreflight(token string) { | |
| headerf(2, "Cloudflare SDK Verification") | |
| sdkHTTPClient := &http.Client{ | |
| Transport: loggingRoundTripper{ | |
| label: "SDK transport", | |
| base: http.DefaultTransport, | |
| }, | |
| } | |
| bulletf("construct Cloudflare client") | |
| client, err := cloudflare.NewWithAPIToken(token, cloudflare.HTTPClient(sdkHTTPClient)) | |
| if err != nil { | |
| subBulletf("error: failed to prepare the Cloudflare authentication: %v", err) | |
| os.Exit(1) | |
| } | |
| subBulletf("`client.base_url`: `%s`", client.BaseURL) | |
| subBulletf("`verify_endpoint`: `%s/user/tokens/verify`", client.BaseURL) | |
| subBulletf("`timeout`: `%v`", authVerifyTimeout) | |
| subBulletf("`client.user_agent`: `%q`", client.UserAgent) | |
| subBulletf("about to call `VerifyAPIToken` with the loaded token") | |
| quickCtx, cancel := context.WithTimeout(context.Background(), authVerifyTimeout) | |
| defer cancel() | |
| start := time.Now() | |
| res, err := client.VerifyAPIToken(quickCtx) | |
| elapsed := time.Since(start) | |
| bulletf("run SDK verify request") | |
| subBulletf("elapsed: `%v`", elapsed) | |
| if err == nil { | |
| subBulletf("verify call succeeded") | |
| subBulletf("`result.id`: `%s`", res.ID) | |
| subBulletf("`result.status`: `%s`", res.Status) | |
| subBulletf("`result.not_before`: `%s`", res.NotBefore.Format(time.RFC3339Nano)) | |
| subBulletf("`result.expires_on`: `%s`", res.ExpiresOn.Format(time.RFC3339Nano)) | |
| // continuing to manual verification for cross-check | |
| runManualVerify(client, token) | |
| return | |
| } | |
| subBulletf("verify call returned error: `%T`: %v", err, err) | |
| var authorizationError *cloudflare.AuthorizationError | |
| var authenticationError *cloudflare.AuthenticationError | |
| var requestError *cloudflare.RequestError | |
| var ratelimitError *cloudflare.RatelimitError | |
| var serviceError *cloudflare.ServiceError | |
| var notFoundError *cloudflare.NotFoundError | |
| switch { | |
| case errors.As(err, &authorizationError): | |
| subBulletf("classified as `cloudflare.AuthorizationError`") | |
| dumpCloudflareError("authorization", authorizationError) | |
| case errors.As(err, &authenticationError): | |
| subBulletf("classified as `cloudflare.AuthenticationError`") | |
| dumpCloudflareError("authentication", authenticationError) | |
| case errors.As(err, &requestError): | |
| subBulletf("classified as `cloudflare.RequestError`") | |
| dumpCloudflareError("request", requestError) | |
| case errors.As(err, &ratelimitError): | |
| subBulletf("classified as `cloudflare.RatelimitError`") | |
| dumpCloudflareError("rate-limit", ratelimitError) | |
| case errors.As(err, &serviceError): | |
| subBulletf("classified as `cloudflare.ServiceError`") | |
| dumpCloudflareError("service", serviceError) | |
| case errors.As(err, ¬FoundError): | |
| subBulletf("classified as `cloudflare.NotFoundError`") | |
| dumpCloudflareError("not-found", notFoundError) | |
| default: | |
| subBulletf("classified as non-Cloudflare typed error") | |
| } | |
| if quickCtx.Err() != nil { | |
| subBulletf("context ended with: `%v`", quickCtx.Err()) | |
| } | |
| runManualVerify(client, token) | |
| } | |
| type cloudflareErrorView interface { | |
| ErrorCodes() []int | |
| ErrorMessages() []string | |
| RayID() string | |
| Type() cloudflare.ErrorType | |
| } | |
| func dumpCloudflareError(label string, err cloudflareErrorView) { | |
| subBulletf("`%s.type`: `%s`", label, err.Type()) | |
| subBulletf("`ray_id`: `%q`", err.RayID()) | |
| subBulletf("`error_codes`: `%v`", err.ErrorCodes()) | |
| subBulletf("`error_messages`: `%v`", err.ErrorMessages()) | |
| } | |
| func runManualVerify(client *cloudflare.API, token string) { | |
| headerf(2, "Manual Verification Request") | |
| bulletf("construct manual request") | |
| url := client.BaseURL + "/user/tokens/verify" | |
| req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) | |
| if err != nil { | |
| subBulletf("error: failed to create manual request: %v", err) | |
| return | |
| } | |
| req.Header.Set("Authorization", "Bearer "+token) | |
| if client.UserAgent != "" { | |
| req.Header.Set("User-Agent", client.UserAgent) | |
| } | |
| req.Header.Set("Content-Type", "application/json") | |
| dump, err := httputil.DumpRequestOut(req, true) | |
| if err != nil { | |
| subBulletf("error: failed to dump manual request: %v", err) | |
| } else { | |
| printFencedBlock("manual request dump", string(dump)) | |
| } | |
| manualHTTPClient := &http.Client{ | |
| Transport: http.DefaultTransport, | |
| Timeout: authVerifyTimeout, | |
| } | |
| start := time.Now() | |
| resp, err := manualHTTPClient.Do(req) | |
| elapsed := time.Since(start) | |
| bulletf("run manual request") | |
| subBulletf("elapsed: `%v`", elapsed) | |
| if err != nil { | |
| subBulletf("manual request returned error: `%T`: %v", err, err) | |
| return | |
| } | |
| defer resp.Body.Close() | |
| respDump, err := httputil.DumpResponse(resp, true) | |
| if err != nil { | |
| subBulletf("error: failed to dump manual response: %v", err) | |
| return | |
| } | |
| printFencedBlock("manual response dump", string(respDump)) | |
| } | |
| type loggingRoundTripper struct { | |
| label string | |
| base http.RoundTripper | |
| } | |
| func (t loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { | |
| base := t.base | |
| if base == nil { | |
| base = http.DefaultTransport | |
| } | |
| dump, err := httputil.DumpRequestOut(req, true) | |
| if err != nil { | |
| bulletf("[%s] failed to dump request: %v", t.label, err) | |
| } else { | |
| printFencedBlock(t.label+" request dump", string(dump)) | |
| } | |
| resp, err := base.RoundTrip(req) | |
| if err != nil { | |
| bulletf("[%s] round trip error: `%T`: %v", t.label, err, err) | |
| return nil, err | |
| } | |
| respDump, dumpErr := httputil.DumpResponse(resp, true) | |
| if dumpErr != nil { | |
| bulletf("[%s] failed to dump response: %v", t.label, dumpErr) | |
| return resp, nil | |
| } | |
| printFencedBlock(t.label+" response dump", string(respDump)) | |
| return resp, nil | |
| } | |
| func printFencedBlock(title string, text string) { | |
| headerf(3, "%s", title) | |
| fmt.Println("````text") | |
| fmt.Print(text) | |
| if !strings.HasSuffix(text, "\n") { | |
| fmt.Println() | |
| } | |
| fmt.Println("````") | |
| fmt.Println() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment