feat(cloudflare): 支持 API Token 连接验证并发布 v1.6.7
This commit is contained in:
@@ -192,12 +192,13 @@ func (h *CloudflareHandler) SaveAppSettings(w http.ResponseWriter, r *http.Reque
|
||||
func (h *CloudflareHandler) TestToken(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Token string `json:"token"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
message, err := h.svc.TestToken(r.Context(), request.Token)
|
||||
message, err := h.svc.TestToken(r.Context(), request.Token, request.Kind)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
|
||||
@@ -74,15 +74,18 @@ type CreateInput struct {
|
||||
type AppSettings struct {
|
||||
DefaultTokenConfigured bool `json:"default_token_configured"`
|
||||
DefaultTokenMasked string `json:"default_token_masked"`
|
||||
APITokenConfigured bool `json:"api_token_configured"`
|
||||
APITokenMasked string `json:"api_token_masked"`
|
||||
Mirror string `json:"mirror"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
}
|
||||
|
||||
// AppSettingsInput 是保存应用配置的入参;Token 留空表示保留原值。
|
||||
type AppSettingsInput struct {
|
||||
Token string `json:"token"`
|
||||
Mirror string `json:"mirror"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
Token string `json:"token"`
|
||||
APIToken string `json:"api_token"`
|
||||
Mirror string `json:"mirror"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
}
|
||||
|
||||
// RuntimeStatus 是隧道运行状态与指标快照。
|
||||
|
||||
@@ -2,8 +2,11 @@ package cloudflared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -211,6 +214,12 @@ func (s *Service) AppSettings(ctx context.Context) (AppSettings, error) {
|
||||
out.DefaultTokenMasked = MaskSecret(plain)
|
||||
}
|
||||
}
|
||||
if row.APITokenEnc != "" {
|
||||
out.APITokenConfigured = true
|
||||
if plain, err := s.secretBox.Decrypt(row.APITokenEnc); err == nil {
|
||||
out.APITokenMasked = MaskSecret(plain)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -230,6 +239,13 @@ func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (App
|
||||
return AppSettings{}, err
|
||||
}
|
||||
}
|
||||
apiTokenEnc := row.APITokenEnc
|
||||
if apiToken := strings.TrimSpace(in.APIToken); apiToken != "" {
|
||||
apiTokenEnc, err = s.secretBox.Encrypt(apiToken)
|
||||
if err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
}
|
||||
mirror := strings.TrimSpace(in.Mirror)
|
||||
if mirror == "" {
|
||||
mirror = row.Mirror
|
||||
@@ -241,7 +257,7 @@ func (s *Service) SaveAppSettings(ctx context.Context, in AppSettingsInput) (App
|
||||
if strings.TrimSpace(network.TransportProtocol) == "" {
|
||||
network = row.Network
|
||||
}
|
||||
if err := s.store.SaveSettings(ctx, tokenEnc, mirror, network); err != nil {
|
||||
if err := s.store.SaveSettings(ctx, tokenEnc, apiTokenEnc, mirror, network); err != nil {
|
||||
return AppSettings{}, err
|
||||
}
|
||||
return s.AppSettings(ctx)
|
||||
@@ -277,8 +293,61 @@ func (s *Service) resolveToken(ctx context.Context, input string) (string, error
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// VerifyAPIToken 调用 Cloudflare 官方 tokens/verify 接口验证 API Token(cfat_...)。
|
||||
func (s *Service) VerifyAPIToken(ctx context.Context, input string) (string, error) {
|
||||
token := strings.TrimSpace(input)
|
||||
if token == "" {
|
||||
row, err := s.store.GetSettingsRaw(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row.APITokenEnc == "" {
|
||||
return "", fmt.Errorf("请填写 API Token(cfat_...),或先在应用配置中保存")
|
||||
}
|
||||
token, err = s.secretBox.Decrypt(row.APITokenEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("API Token 解密失败: %w", err)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求 Cloudflare API 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var parsed struct {
|
||||
Success bool `json:"success"`
|
||||
Result struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("Cloudflare API 响应格式无效(HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if !parsed.Success || parsed.Result.Status != "active" {
|
||||
message := "API Token 无效或未激活"
|
||||
if len(parsed.Errors) > 0 && strings.TrimSpace(parsed.Errors[0].Message) != "" {
|
||||
message = parsed.Errors[0].Message
|
||||
}
|
||||
return "", fmt.Errorf("%s", message)
|
||||
}
|
||||
return fmt.Sprintf("API Token 有效(状态:%s)", parsed.Result.Status), nil
|
||||
}
|
||||
|
||||
// TestToken 用 cloudflared 临时启动一次连接,验证 token 能否注册到 Cloudflare 边缘。
|
||||
func (s *Service) TestToken(ctx context.Context, input string) (string, error) {
|
||||
func (s *Service) TestToken(ctx context.Context, input, kind string) (string, error) {
|
||||
if kind == "api" || strings.HasPrefix(strings.TrimSpace(input), "cfat_") {
|
||||
return s.VerifyAPIToken(ctx, input)
|
||||
}
|
||||
token, err := s.resolveToken(ctx, input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -97,35 +97,37 @@ func (s *Store) SetRuntime(ctx context.Context, id int64, status, lastError stri
|
||||
|
||||
// settingsRow 是应用配置的原始行;TokenEnc 只在 Service 内部解密,不直接回给前端。
|
||||
type settingsRow struct {
|
||||
TokenEnc string
|
||||
Mirror string
|
||||
Network NetworkSettings
|
||||
TokenEnc string
|
||||
APITokenEnc string
|
||||
Mirror string
|
||||
Network NetworkSettings
|
||||
}
|
||||
|
||||
func (s *Store) GetSettingsRaw(ctx context.Context) (settingsRow, error) {
|
||||
var tokenEnc, mirror, networkJSON string
|
||||
var tokenEnc, apiTokenEnc, mirror, networkJSON string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(default_token_enc, ''), COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
|
||||
SELECT COALESCE(default_token_enc, ''), COALESCE(api_token_enc, ''),
|
||||
COALESCE(mirror, 'official'), COALESCE(network_json, '{}')
|
||||
FROM cf_settings WHERE id = 1
|
||||
`).Scan(&tokenEnc, &mirror, &networkJSON)
|
||||
`).Scan(&tokenEnc, &apiTokenEnc, &mirror, &networkJSON)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return settingsRow{Mirror: "official", Network: DefaultNetworkSettings()}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return settingsRow{}, err
|
||||
}
|
||||
row := settingsRow{TokenEnc: tokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
|
||||
row := settingsRow{TokenEnc: tokenEnc, APITokenEnc: apiTokenEnc, Mirror: mirror, Network: DefaultNetworkSettings()}
|
||||
_ = json.Unmarshal([]byte(networkJSON), &row.Network)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveSettings(ctx context.Context, tokenEnc, mirror string, network NetworkSettings) error {
|
||||
func (s *Store) SaveSettings(ctx context.Context, tokenEnc, apiTokenEnc, mirror string, network NetworkSettings) error {
|
||||
networkJSON, _ := json.Marshal(network)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE cf_settings
|
||||
SET default_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
|
||||
SET default_token_enc = ?, api_token_enc = ?, mirror = ?, network_json = ?, updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
`, tokenEnc, mirror, string(networkJSON))
|
||||
`, tokenEnc, apiTokenEnc, mirror, string(networkJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user