diff --git a/cmd/ask.go b/cmd/ask.go index 7c05d52..88fb0ce 100644 --- a/cmd/ask.go +++ b/cmd/ask.go @@ -23,6 +23,7 @@ import ( cfzerotrust "github.com/bgdnvk/clanker/internal/cloudflare/zerotrust" "github.com/bgdnvk/clanker/internal/dbcontext" "github.com/bgdnvk/clanker/internal/digitalocean" + "github.com/bgdnvk/clanker/internal/tencent" "github.com/bgdnvk/clanker/internal/flyio" "github.com/bgdnvk/clanker/internal/gcp" ghclient "github.com/bgdnvk/clanker/internal/github" @@ -118,6 +119,7 @@ Examples: includeFlyio, _ := cmd.Flags().GetBool("flyio") includeRailway, _ := cmd.Flags().GetBool("railway") includeVerda, _ := cmd.Flags().GetBool("verda") + includeTencent, _ := cmd.Flags().GetBool("tencent") sreMode, _ := cmd.Flags().GetBool("sre") includeTerraform, _ := cmd.Flags().GetBool("terraform") includeIAM, _ := cmd.Flags().GetBool("iam") @@ -384,6 +386,21 @@ Examples: }) } + if strings.EqualFold(strings.TrimSpace(makerPlan.Provider), "tencent") { + tcCreds := tencent.ResolveCredentials() + if tcCreds.SecretID == "" || tcCreds.SecretKey == "" { + return fmt.Errorf("tencent credentials are required for --apply (set tencent.secret_id / tencent.secret_key, TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY, or TENCENT_SECRET_ID / TENCENT_SECRET_KEY)") + } + return maker.ExecuteTencentPlan(ctx, makerPlan, maker.ExecOptions{ + TencentSecretID: tcCreds.SecretID, + TencentSecretKey: tcCreds.SecretKey, + TencentRegion: tcCreds.Region, + Writer: os.Stdout, + Destroyer: destroyer, + Debug: debug, + }) + } + // Resolve AWS profile/region for execution. targetProfile := resolveAWSProfile(profile) @@ -521,6 +538,7 @@ Examples: explicitVercel := cmd.Flags().Changed("vercel") && includeVercel explicitRailway := cmd.Flags().Changed("railway") && includeRailway explicitVerda := cmd.Flags().Changed("verda") && includeVerda + explicitTencent := cmd.Flags().Changed("tencent") && includeTencent explicitCount := 0 if explicitGCP { explicitCount++ @@ -549,8 +567,11 @@ Examples: if explicitVerda { explicitCount++ } + if explicitTencent { + explicitCount++ + } if explicitCount > 1 { - return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda) together with --maker") + return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda, --tencent) together with --maker") } switch { case explicitHetzner: @@ -580,6 +601,9 @@ Examples: case explicitVerda: makerProvider = "verda" makerProviderReason = "explicit" + case explicitTencent: + makerProvider = "tencent" + makerProviderReason = "explicit" default: svcCtx := routing.InferContext(questionForRouting(question)) if svcCtx.Cloudflare { @@ -634,6 +658,8 @@ Examples: prompt = maker.RailwayPlanPromptWithMode(question, destroyer) case "verda": prompt = maker.VerdaPlanPromptWithMode(question, destroyer) + case "tencent": + prompt = maker.TencentPlanPromptWithMode(question, destroyer) default: prompt = maker.PlanPromptWithMode(question, destroyer) } @@ -696,7 +722,7 @@ Examples: // Handle GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, Verda, and Railway plans (output directly, no enrichment) providerLower := strings.ToLower(strings.TrimSpace(plan.Provider)) - if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" { + if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" || providerLower == "tencent" { if plan.CreatedAt.IsZero() { plan.CreatedAt = time.Now().UTC() } @@ -847,6 +873,11 @@ Format as a professional compliance table suitable for government security docum return handleVerdaQuery(cmd.Context(), question, debug) } + // Handle explicit --tencent flag + if includeTencent && !makerMode { + return handleTencentQuery(context.Background(), question, debug) + } + if !includeAWS && !includeGitHub && !includeTerraform && !includeGCP && !includeAzure && !includeCloudflare && !includeDigitalOcean && !includeHetzner && !includeVercel && !includeFlyio && !includeRailway && !includeVerda && !includeDB { routingQuestion := questionForRouting(question) @@ -1425,6 +1456,7 @@ func init() { askCmd.Flags().Bool("flyio", false, "Include Fly.io context") askCmd.Flags().Bool("railway", false, "Include Railway context") askCmd.Flags().Bool("verda", false, "Include Verda Cloud (GPU/AI) infrastructure context") + askCmd.Flags().Bool("tencent", false, "Include Tencent Cloud infrastructure context") askCmd.Flags().Bool("sre", false, "Use adaptive Clanker SRE discovery context") askCmd.Flags().Bool("github", false, "Include GitHub repository context") askCmd.Flags().Bool("cicd", false, "Include CI/CD context (currently GitHub Actions)") @@ -2268,6 +2300,68 @@ Provide a clear, concise answer based on the data above. If the data doesn't con return nil } +// handleTencentQuery delegates a Tencent Cloud query to the tencent client. +// Mirrors handleDigitalOceanQuery: gather relevant context from the SDK, +// stuff it into the prompt, hand to the configured AI provider. +func handleTencentQuery(ctx context.Context, question string, debug bool) error { + if debug { + fmt.Println("Delegating query to Tencent Cloud agent...") + } + + creds := tencent.ResolveCredentials() + client, err := tencent.NewClient(creds, debug) + if err != nil { + return err + } + + tcContext, err := client.GetRelevantContext(ctx, question) + if err != nil { + return fmt.Errorf("failed to get Tencent Cloud context: %w", err) + } + + provider := viper.GetString("ai.default_provider") + if provider == "" { + provider = "openai" + } + + var apiKey string + switch provider { + case "gemini", "gemini-api": + apiKey = "" + case "openai": + apiKey = resolveOpenAIKey("") + case "anthropic": + apiKey = resolveAnthropicKey("") + case "cohere": + apiKey = resolveCohereKey("") + case "deepseek": + apiKey = resolveDeepSeekKey("") + case "minimax": + apiKey = resolveMiniMaxKey("") + default: + apiKey = viper.GetString("ai.api_key") + } + + aiClient := ai.NewClient(provider, apiKey, debug, provider) + + prompt := fmt.Sprintf(`You are a Tencent Cloud infrastructure expert. Answer the user's question using the inventory data below. Tencent service abbreviations: CVM=Cloud Virtual Machine, VPC=Virtual Private Cloud, SG=Security Group, COS=Cloud Object Storage, CLB=Cloud Load Balancer, TKE=Tencent Kubernetes Engine, CDB=TencentDB for MySQL. + +Tencent Cloud Context: +%s + +User Question: %s + +Provide a clear, concise answer based on the data above. Cite specific resource IDs (ins-*, vpc-*, sg-*) when relevant. If the data is insufficient, say what is missing and suggest the specific clanker subcommand that would surface it (e.g. clanker tencent list cvm --all-regions, clanker tencent sg-rules ).`, tcContext, question) + + response, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return fmt.Errorf("failed to get AI response: %w", err) + } + + fmt.Println(response) + return nil +} + func resolveHetznerToken(ctx context.Context, debug bool) (string, error) { apiToken := hetzner.ResolveAPIToken() if apiToken != "" { diff --git a/cmd/root.go b/cmd/root.go index e5b6358..f7b6ead 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/bgdnvk/clanker/internal/gcp" "github.com/bgdnvk/clanker/internal/hetzner" "github.com/bgdnvk/clanker/internal/railway" + "github.com/bgdnvk/clanker/internal/tencent" "github.com/bgdnvk/clanker/internal/vercel" "github.com/bgdnvk/clanker/internal/verda" "github.com/spf13/cobra" @@ -136,6 +137,11 @@ func init() { verdaCmd := verda.CreateVerdaCommands() AddVerdaAskCommand(verdaCmd) rootCmd.AddCommand(verdaCmd) + + // Register Tencent Cloud static commands. Natural-language queries + // (`clanker ask --tencent ...`) will land in a later phase. + tencentCmd := tencent.CreateTencentCommands() + rootCmd.AddCommand(tencentCmd) } // initConfig reads in config file and ENV variables if set. diff --git a/cmd/server.go b/cmd/server.go new file mode 100644 index 0000000..76ced72 --- /dev/null +++ b/cmd/server.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/bgdnvk/clanker/internal/api" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + var ( + port int + host string + token string + insecure bool + corsOrigin string + debug bool + aiProfile string + openaiKey string + openaiModel string + anthropicKey string + geminiKey string + localModelInferenceURL string + noThinking bool + ) + + serverCmd := &cobra.Command{ + Use: "server", + Short: "Run the Clanker HTTP API server", + Long: `Start the HTTP API server that wraps the Clanker agent. + +This is the gateway for the Clanker web dashboard. Inventory + maker +apply + plan-generation endpoints all live here. + +Auth: pass --token or set CLANKER_API_TOKEN. The server refuses to start +without one — POST /api/v1/maker/apply can mutate real cloud resources, so +unauthenticated startup is gated behind an explicit --insecure flag. + +AI provider: pass the same --ai-profile / --openai-key / --openai-model +/ --local-model-inference-url flags you'd give to ` + "`clanker ask`" + ` so the +plan-generation endpoint can call your configured LLM. Server reads from +~/.clanker.yaml as well, so flags only override what's already there. + +Examples: + # Token-gated server (recommended) + clanker server --port 8080 --token "$(openssl rand -hex 32)" + + # Trusted-network mode (no auth) — NEVER on a public address + clanker server --port 8080 --host 127.0.0.1 --insecure + + # With vLLM-backed plan generation + clanker server --port 8080 \ + --ai-profile openai \ + --openai-model qwen3.6-27b-fp8 \ + --openai-key "$VLLM_API_KEY" \ + --local-model-inference-url "$VLLM_BASE_URL/v1"`, + RunE: func(cmd *cobra.Command, args []string) error { + resolved := strings.TrimSpace(token) + if resolved == "" { + resolved = strings.TrimSpace(os.Getenv("CLANKER_API_TOKEN")) + } + addr := fmt.Sprintf("%s:%d", host, port) + api.SetVersion(Version) + + // Push AI config into viper so api handlers building ai.NewClient + // pick the same values the CLI does. Empty flags leave existing + // config (from ~/.clanker.yaml) untouched. + if strings.TrimSpace(aiProfile) != "" { + viper.Set("ai.default_provider", aiProfile) + } + if strings.TrimSpace(openaiKey) != "" { + viper.Set("ai.providers.openai.api_key", openaiKey) + } + if strings.TrimSpace(openaiModel) != "" { + viper.Set("ai.providers.openai.model", openaiModel) + } + if strings.TrimSpace(anthropicKey) != "" { + viper.Set("ai.providers.anthropic.api_key", anthropicKey) + } + if strings.TrimSpace(geminiKey) != "" { + viper.Set("ai.providers.gemini-api.api_key", geminiKey) + } + if strings.TrimSpace(localModelInferenceURL) != "" { + viper.Set("ai.providers.openai.local_model_inference_url", strings.TrimSpace(localModelInferenceURL)) + } + if noThinking { + viper.Set("ai.providers.openai.chat_template_kwargs", map[string]interface{}{"enable_thinking": false}) + } + + srv := api.New(api.Config{ + Addr: addr, + Token: resolved, + Insecure: insecure, + CORSOrigin: corsOrigin, + Debug: debug, + }, log.New(os.Stderr, "", log.LstdFlags)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "[server] shutting down") + cancel() + }() + return srv.Run(ctx) + }, + } + + serverCmd.Flags().IntVar(&port, "port", 8080, "Port to listen on") + serverCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to bind on (use 0.0.0.0 for all interfaces)") + serverCmd.Flags().StringVar(&token, "token", "", "Bearer token required for /api/v1/* (or set CLANKER_API_TOKEN). Required unless --insecure is passed.") + serverCmd.Flags().BoolVar(&insecure, "insecure", false, "Allow startup without a bearer token. NEVER use on a publicly reachable address — /api/v1/maker/apply mutates real cloud resources.") + serverCmd.Flags().StringVar(&corsOrigin, "cors-origin", "", "Value for Access-Control-Allow-Origin (defaults to http://localhost:4173 — pass an explicit value for non-localhost dashboards; \"*\" allowed but discouraged)") + serverCmd.Flags().BoolVar(&debug, "server-debug", false, "Log every request, not just errors") + + // LLM provider flags — push into viper so the plan-generation endpoint + // has the same options the CLI exposes. + serverCmd.Flags().StringVar(&aiProfile, "ai-profile", "", "AI provider profile (openai, gemini-api, anthropic, cohere, ...)") + serverCmd.Flags().StringVar(&openaiKey, "openai-key", "", "OpenAI API key (or any OpenAI-compatible endpoint key, e.g. vLLM)") + serverCmd.Flags().StringVar(&openaiModel, "openai-model", "", "OpenAI / OpenAI-compatible model name") + serverCmd.Flags().StringVar(&anthropicKey, "anthropic-key", "", "Anthropic API key") + serverCmd.Flags().StringVar(&geminiKey, "gemini-key", "", "Gemini API key") + serverCmd.Flags().StringVar(&localModelInferenceURL, "local-model-inference-url", "", "OpenAI-compatible base URL for local/self-hosted models (e.g. https://x.runpod.net/v1)") + serverCmd.Flags().BoolVar(&noThinking, "no-thinking", false, "Disable Qwen3-style internal reasoning trace via chat_template_kwargs.enable_thinking=false. 14x faster plan generation with reasoning-capable models.") + + rootCmd.AddCommand(serverCmd) +} diff --git a/go.mod b/go.mod index 03593a7..b7cd88f 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,30 @@ require ( github.com/mark3labs/mcp-go v0.46.0 github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos v1.3.89 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing v1.3.84 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam v1.3.42 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs v1.3.96 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb v1.3.94 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.90 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.3.83 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit v1.3.40 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls v1.3.97 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.98 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.89 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb v1.3.98 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc v1.3.96 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse v1.3.91 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb v1.3.93 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor v1.3.96 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres v1.3.89 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis v1.3.79 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.3.94 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.3.93 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke v1.3.86 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc v1.3.83 + github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf v1.3.95 + github.com/tencentyun/cos-go-sdk-v5 v0.7.73 golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 @@ -67,6 +91,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect github.com/aws/smithy-go v1.24.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -94,6 +119,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mozillazg/go-httpheader v0.2.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect diff --git a/go.sum b/go.sum index 714fb82..257804b 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4p github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= @@ -135,6 +137,7 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -166,6 +169,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4= github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= @@ -176,6 +180,7 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -228,8 +233,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= @@ -244,6 +252,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -276,6 +285,71 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos v1.3.89 h1:QQPeJ8hTyMR7hBMU1abPUg7KNjsAy731AOufr8CaV5o= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos v1.3.89/go.mod h1:fxUYp8c0qeVQXEBviL0a3h+PiJ1ykjcBUBbk3l1YiQA= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing v1.3.84 h1:+qRAf9wQZ4y7uQu6k1iSR6yr9YZYaPXgfFHGDP1OwF8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing v1.3.84/go.mod h1:1oh8/NgeQYLFSkBvQskOxbhKB4/VWuJZfO/ikdarD+Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam v1.3.42 h1:3mFUaXMiKT8d+Eoqw6IDxY4BN6LTgfWl70/ISd0llYg= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam v1.3.42/go.mod h1:WfHCQ+3UqfUSV81/1hvNEPxhgiHt5iWG5XIXNYOHzB8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs v1.3.96 h1:wY6yfxHFM93E2gVSMMlz4zHl3eZ9icaQsl9J3TGOEco= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs v1.3.96/go.mod h1:416v6pNvfM1lLMSX+ce+fvvh67lW3JfldwQuRoiUVqA= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb v1.3.94 h1:OW9fV8GIGRNhniE8oGS+0pL0kxBHj4N9t6Yj3ylaqWE= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb v1.3.94/go.mod h1:v5cDZ49IfyHIC9DLTWVFCE7UXBSO9Hbk9NEw5/90LEw= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.90 h1:e2tRX+Lpe++gCcuc3M9U6yShawvrmSJHZ4a54P/edMY= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.90/go.mod h1:c2a56IWqM+zTJhogBziVLfHB4fzGFjC62zPR6pv31mM= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.3.83 h1:onmUcZqiiuLR87iqbIDNc+vSTGU4M3rsJEcxZ8aBbCk= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.3.83/go.mod h1:au4ZKzh/tEb8cEO4cmnuz9kMO2U0X++O7fa6FaJFU50= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit v1.3.40 h1:bhPBpfz0w3mdVyEolvXrtELUf+gE00O0WnAYIHZq70s= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit v1.3.40/go.mod h1:taTki0q8SHBIUFhjtnDA5UkiVic/WaPQzTqoXeGH3Ns= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls v1.3.97 h1:a805ESTsZX3/0kh+vbkr4431mPOKiEqaRH4a/HJZVBs= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls v1.3.97/go.mod h1:z+hxNauOwY9m5reiSC++ahlAHJIJUYVpQApKMeQ5feI= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.40/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.42/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.79/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.83/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.84/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.86/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.89/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.90/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.91/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.93/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.94/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.95/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.96/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.97/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.98 h1:e0iS/5AsTRU8R0yf+dhxYCdmkCe/Zo9GJlZf2e4EzEc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.98/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.89 h1:1EA1FwWKJQ4YBOFrfW6voG7DSwDBdqQmyHYK0u5NVgc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.89/go.mod h1:6Akt0CmkjfpwPnNCf2FtSQ8cubkz9gct5gVS8vQ2/n8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb v1.3.98 h1:25VMqxoTVTxIw3jjkTM3jOd5REHOdO0MWsjJcEAIPB8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb v1.3.98/go.mod h1:nONxZJB1/HmLhtWgqlspqkqtN6KXeb0lUe4wyoM228U= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc v1.3.96 h1:1GqEKvpF/pAlbiHOQ7y3i7CSKengjeixBleW2IBtbEc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc v1.3.96/go.mod h1:j0TbbNR2k4CqnJPuUC3T9YFgayrQV1iAslqJe2QfeXw= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse v1.3.91 h1:0F2WVXSiprG9/46/RtsIipNbNQNgbKvBxTzobIw0hs8= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse v1.3.91/go.mod h1:jt5GKFUI1+/C9tmVtgsTqgInKwDImVkd97QU0Vv8GFo= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb v1.3.93 h1:S1nGvc80vJzz1mYo768bX6K5PiwndGDd7ZHRFvAXoVU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb v1.3.93/go.mod h1:ehQJQUT0XZ05yrCubE3TpAoRy1slXglF/iko84MrYwQ= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor v1.3.96 h1:ElnooOem4hFA+ypzt3TBEv0bfMI9xONMzFyq/Pn6PFM= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor v1.3.96/go.mod h1:hDnlegZIepyVTvcYl5X6cYL50nJa9c0qXodzR2g4q50= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres v1.3.89 h1:4HdSGx6XJb0/HVLKzWp7YdqvdeiISPAYsMjL9IJaXPI= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres v1.3.89/go.mod h1:7JNIsh6G2GmRhPIYUM4Sa/x1t57cWIBAempwHoMDMp4= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis v1.3.79 h1:hnlFMUakkWwZE3gKj0y34K56tu6YUVSBJO0nU9D0ZZ0= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis v1.3.79/go.mod h1:1ToX770RaMyIeCrCoaojjv3a1HSaJxsBIpEhMDO/OY4= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.3.94 h1:QmXyHu/ptSZ3KZ/o69//EdSYkVASQXHktwDrXz/Vko4= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.3.94/go.mod h1:UyOuPaTkPfvqsx0Mpr6Rx7ghIro475egEHQ/vyq9b7o= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.3.93 h1:Dh2TdtifC9wJWrI3w+WJnXn0kx/6XCaNNw4C53nSp4s= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.3.93/go.mod h1:avEzuUK2bQ4MUskG92IxyqyIizc9IO+XkdO7LxRh0Bc= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke v1.3.86 h1:vTpLGPpzAbZHOv0F6hmy8C9IBwJIUfhTrXH0vi7eiGA= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke v1.3.86/go.mod h1:WLYwnfkYAsDBubUQmjKLN9wg+e0UeNVyILL3c46RcbE= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc v1.3.83 h1:ie9YH+k8r+pgZMmexGHPQVxpqjqHqb6aNLwEQ0grnWQ= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc v1.3.83/go.mod h1:YEf1XryuGZaXip8sc2zUzFn2H81aIOqh2I7vBlEXVFk= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf v1.3.95 h1:7kkSVeUUOi1Sc7K/neN7y3qhdebQrQOqs0HScHBDGOg= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf v1.3.95/go.mod h1:JHnW2i0WR2wq62Z41prri06y5pLMZon7TavXGVpnYfQ= +github.com/tencentyun/cos-go-sdk-v5 v0.7.73 h1:uFfgp1A7cQaAGR6QP9DsIkoEQ67b8ewj5r1RV6XB540= +github.com/tencentyun/cos-go-sdk-v5 v0.7.73/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= diff --git a/internal/ai/client.go b/internal/ai/client.go index 12385f3..9b0887a 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -297,8 +297,15 @@ type ClaudeContent struct { // OpenAI types (keeping for compatibility) type OpenAIRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` + Model string `json:"model"` + Messages []Message `json:"messages"` + // ChatTemplateKwargs is a vLLM/SGLang vendor extension. The most useful + // case is `enable_thinking: false` for Qwen3-style reasoning models — + // it skips the internal "thinking" trace and emits the answer directly, + // dropping plan-generation latency from ~60-90s to ~1-10s. Empty map + // omits the field entirely thanks to omitempty, so the request shape + // is unchanged for non-reasoning providers. + ChatTemplateKwargs map[string]interface{} `json:"chat_template_kwargs,omitempty"` } type Message struct { @@ -1103,6 +1110,9 @@ func (c *Client) askOpenAI(ctx context.Context, prompt string) (string, error) { }, }, } + if v := viper.GetStringMap("ai.providers.openai.chat_template_kwargs"); len(v) > 0 { + request.ChatTemplateKwargs = v + } jsonData, err := json.Marshal(request) if err != nil { @@ -1216,6 +1226,9 @@ func (c *Client) askGitHubModels(ctx context.Context, prompt string) (string, er Content: sanitizeASCII(prompt), }}, } + if v := viper.GetStringMap("ai.providers.openai.chat_template_kwargs"); len(v) > 0 { + reqBody.ChatTemplateKwargs = v + } jsonData, err := json.Marshal(reqBody) if err != nil { @@ -2209,6 +2222,9 @@ func (c *Client) askOpenAIWithHistory(ctx context.Context, conv *ConversationCon Model: model, Messages: messages, } + if v := viper.GetStringMap("ai.providers.openai.chat_template_kwargs"); len(v) > 0 { + reqBody.ChatTemplateKwargs = v + } jsonData, err := json.Marshal(reqBody) if err != nil { @@ -2295,6 +2311,9 @@ func (c *Client) askGitHubModelsWithHistory(ctx context.Context, conv *Conversat } reqBody := OpenAIRequest{Model: model, Messages: messages} + if v := viper.GetStringMap("ai.providers.openai.chat_template_kwargs"); len(v) > 0 { + reqBody.ChatTemplateKwargs = v + } jsonData, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) diff --git a/internal/api/history.go b/internal/api/history.go new file mode 100644 index 0000000..3c1cbe6 --- /dev/null +++ b/internal/api/history.go @@ -0,0 +1,83 @@ +package api + +import ( + "sync" + "time" +) + +const ( + // historyCap is the ring buffer size — small enough to keep in memory, + // big enough to cover a working session. Persistence to disk is a v2 + // concern; for now the history clears when the server restarts. + historyCap = 50 + + // outputTruncateBytes caps what we keep per record. Apply output is + // already small (a few KB), but a multi-command plan could grow. + outputTruncateBytes = 16 << 10 // 16 KiB +) + +// ApplyRecord is what /api/v1/maker/history returns for each past apply. +// +// Output is truncated server-side so the list endpoint stays cheap. For full +// output a future endpoint can fetch by ID. +type ApplyRecord struct { + ID int64 `json:"id"` + StartedAt time.Time `json:"started_at"` + Provider string `json:"provider"` + Status string `json:"status"` // "ok" or "error" + Duration string `json:"duration"` + Destroyer bool `json:"destroyer"` + CommandCount int `json:"command_count"` + DestructiveCount int `json:"destructive_count"` + Summary string `json:"summary,omitempty"` + Question string `json:"question,omitempty"` + Error string `json:"error,omitempty"` + Output string `json:"output,omitempty"` + OutputTruncated bool `json:"output_truncated,omitempty"` +} + +// history is the singleton ring buffer. Concurrent appends from any handler +// are safe; reads return a defensive copy so the response can be marshalled +// without holding the lock. +type history struct { + mu sync.Mutex + items []ApplyRecord + nextID int64 +} + +func newHistory() *history { + return &history{items: make([]ApplyRecord, 0, historyCap)} +} + +func (h *history) append(rec ApplyRecord) ApplyRecord { + h.mu.Lock() + defer h.mu.Unlock() + h.nextID++ + rec.ID = h.nextID + if len(rec.Output) > outputTruncateBytes { + rec.Output = rec.Output[:outputTruncateBytes] + rec.OutputTruncated = true + } + if len(h.items) >= historyCap { + // drop oldest + copy(h.items, h.items[1:]) + h.items = h.items[:len(h.items)-1] + } + h.items = append(h.items, rec) + return rec +} + +// list returns records newest-first. limit<=0 means everything. +func (h *history) list(limit int) []ApplyRecord { + h.mu.Lock() + defer h.mu.Unlock() + n := len(h.items) + out := make([]ApplyRecord, 0, n) + for i := n - 1; i >= 0; i-- { + out = append(out, h.items[i]) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..91c4fd5 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,95 @@ +package api + +import ( + "crypto/subtle" + "net/http" + "strings" + "time" +) + +// middleware wraps the router with auth, CORS, and access logging in the +// order: CORS → auth → log → handler. CORS runs first so preflight requests +// short-circuit before auth. +func (s *Server) middleware(next http.Handler) http.Handler { + return s.corsMiddleware(s.authMiddleware(s.logMiddleware(next))) +} + +func (s *Server) corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", s.cfg.CORSOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Max-Age", "600") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Health is unauthenticated so liveness probes don't need the token. + if r.URL.Path == "/api/v1/health" { + next.ServeHTTP(w, r) + return + } + if strings.TrimSpace(s.cfg.Token) == "" { + next.ServeHTTP(w, r) + return + } + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + s.log401(r, "missing or non-bearer Authorization header") + writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token") + return + } + // ConstantTimeCompare avoids a timing side-channel that would let an + // attacker recover the token byte-by-byte by measuring response latency + // across many requests. The byte-length check is constant-time-safe + // because Tokens are configured at startup and never user-controlled + // in length, so leaking that the lengths differ does not leak content. + got := []byte(strings.TrimSpace(auth[len(prefix):])) + want := []byte(s.cfg.Token) + if len(got) != len(want) || subtle.ConstantTimeCompare(got, want) != 1 { + s.log401(r, "bearer token mismatch") + writeError(w, http.StatusUnauthorized, "unauthorized", "missing or invalid bearer token") + return + } + next.ServeHTTP(w, r) + }) +} + +// log401 records a rejected authentication attempt. authMiddleware is the +// OUTERMOST handler-wrapper (CORS → auth → log → handler), so a 401 short- +// circuits before logMiddleware runs and the rejection would otherwise be +// silent in stderr — which makes prod credential rotations or attacks +// invisible. We log unconditionally rather than gated on s.cfg.Debug so +// "why is my dashboard returning 401?" is answerable from the logs. +func (s *Server) log401(r *http.Request, reason string) { + s.logger.Printf("[api] 401 %s %s from %s — %s", + r.Method, r.URL.RequestURI(), r.RemoteAddr, reason) +} + +func (s *Server) logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + if s.cfg.Debug || rec.status >= 400 { + s.logger.Printf("[api] %s %s -> %d (%s)", r.Method, r.URL.RequestURI(), rec.status, time.Since(start)) + } + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..b02b81f --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,688 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/tencent" +) + +// regionRegex matches every Tencent public region prefix that currently +// exists: ap-* (Asia-Pacific), na-* (North America), eu-* (Europe), sa-* +// (South America), and cn-* (mainland China). The optional numeric/letter +// suffix accommodates regions like `ap-shanghai-fsi`. Region NAMES never +// contain digits (those are zone-level only), so we don't accept them. +// +// Reviewer flagged that the unvalidated ?region= value was reaching the +// SDK signing layer verbatim — enabling enumeration of arbitrary +// Tencent endpoints and potentially driving unintended API charges. +var regionRegex = regexp.MustCompile(`^(ap|na|eu|sa|cn)-[a-z]+(-[a-z]+)?$`) + +// errInvalidRegion is returned by validateRegion so the handler chokepoint +// can surface it as a 400 instead of the catch-all 401. +type errInvalidRegion struct{ value string } + +func (e *errInvalidRegion) Error() string { + return fmt.Sprintf("invalid region %q: must match prefix-name (e.g. ap-singapore, na-ashburn, eu-frankfurt)", e.value) +} + +// validateRegion accepts an empty string (the daemon falls back to its +// configured default) or one of the patterns described above. Anything +// else is rejected before the value can reach the Tencent SDK. +func validateRegion(s string) error { + if s == "" { + return nil + } + if !regionRegex.MatchString(s) { + return &errInvalidRegion{value: s} + } + return nil +} + +func (s *Server) registerRoutes() { + s.mux.HandleFunc("GET /api/v1/health", s.handleHealth) + s.mux.HandleFunc("GET /api/v1/version", s.handleVersion) + + // Tencent read endpoints + s.mux.HandleFunc("GET /api/v1/tencent/regions", s.handleTencentRegions) + s.mux.HandleFunc("GET /api/v1/tencent/resources/{type}", s.handleTencentResources) + s.mux.HandleFunc("GET /api/v1/tencent/sg-rules/{id}", s.handleTencentSGRules) + s.mux.HandleFunc("GET /api/v1/tencent/kubeconfig/{cluster_id}", s.handleTencentKubeconfig) + + // Topology (Phase 7) + s.mux.HandleFunc("GET /api/v1/tencent/topology", s.handleTencentTopology) + s.mux.HandleFunc("GET /api/v1/tencent/scan/public-exposure", s.handleTencentPublicExposure) + s.mux.HandleFunc("GET /api/v1/tencent/scan/clb-exposure", s.handleTencentCLBExposure) + s.mux.HandleFunc("GET /api/v1/tencent/scan/idle-eips", s.handleTencentIdleEIPs) + s.mux.HandleFunc("GET /api/v1/tencent/scan/unencrypted-cbs", s.handleTencentUnencryptedCBS) + s.mux.HandleFunc("GET /api/v1/tencent/scan/cert-expiry", s.handleTencentCertExpiry) + s.mux.HandleFunc("GET /api/v1/tencent/scan/cam-hygiene", s.handleTencentCAMHygiene) + s.mux.HandleFunc("GET /api/v1/tencent/scan/db-exposure", s.handleTencentDBExposure) + s.mux.HandleFunc("GET /api/v1/tencent/scan/waf-coverage", s.handleTencentWAFCoverage) + s.mux.HandleFunc("GET /api/v1/tencent/scan/antiddos-coverage", s.handleTencentAntiDDoSCoverage) + s.mux.HandleFunc("GET /api/v1/tencent/scan/audit-coverage", s.handleTencentAuditCoverage) + s.mux.HandleFunc("GET /api/v1/tencent/metrics/cvm", s.handleTencentCVMMetrics) + s.mux.HandleFunc("GET /api/v1/tencent/metrics/lighthouse", s.handleTencentLighthouseMetrics) + s.mux.HandleFunc("GET /api/v1/tencent/cost/by-product", s.handleTencentCostByProduct) + s.mux.HandleFunc("GET /api/v1/tencent/cost/resources", s.handleTencentCostResources) + s.mux.HandleFunc("GET /api/v1/tencent/cost/vouchers", s.handleTencentCostVouchers) + s.mux.HandleFunc("GET /api/v1/tencent/cost/voucher-usage/{voucher_id}", s.handleTencentCostVoucherUsage) + s.mux.HandleFunc("GET /api/v1/tencent/cost/voucher-by-owner", s.handleTencentCostVoucherByOwner) + + // Renewal/expiry sweep — backs the cron alert and the dashboard panel. + s.mux.HandleFunc("GET /api/v1/tencent/expiry", s.handleTencentExpiry) + + // Maker (Phase 6) — apply a plan generated by `clanker ask --maker`. + s.mux.HandleFunc("POST /api/v1/maker/apply", s.handleMakerApply) + s.mux.HandleFunc("GET /api/v1/maker/history", s.handleMakerHistory) + s.mux.HandleFunc("POST /api/v1/maker/plan", s.handleMakerPlan) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeData(w, map[string]interface{}{ + "ok": true, + "uptime": time.Since(s.started).Round(time.Second).String(), + }) +} + +// Version is wired by cmd/server.go via SetVersion so we don't import cmd here +// and create a cycle. +var serverVersion = "dev" + +// SetVersion sets the version string returned by the /api/v1/version endpoint. +// Called once from cmd/server.go after main() initialises cmd.Version. +func SetVersion(v string) { serverVersion = v } + +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + writeData(w, map[string]string{"version": serverVersion}) +} + +func (s *Server) handleTencentRegions(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + regions, err := client.ListAllRegions() + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeData(w, regions) +} + +func (s *Server) handleTencentResources(w http.ResponseWriter, r *http.Request) { + resourceType := strings.ToLower(strings.TrimSpace(r.PathValue("type"))) + if resourceType == "" { + writeError(w, http.StatusBadRequest, "missing_param", "resource type is required") + return + } + + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + + // We reuse context.go's JSON gather funcs since they already emit the + // shape we want to return. Each function targets the client's current + // region; multi-region fan-out for HTTP is a future enhancement. + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + // Call the typed gather for the requested resource type directly. + // Earlier revisions also invoked GetRelevantContext (the multi-section + // LLM gather) before this and discarded the result — doubling every + // SDK call. The HTTP endpoint only needs the one typed section. + body, err := gatherTencentByType(ctx, client, resourceType) + if err != nil { + // Service not enabled or unsupported — return an empty list with + // the underlying reason so the dashboard can show "no items + reason". + writeJSON(w, http.StatusOK, map[string]interface{}{ + "data": []interface{}{}, + "warning": err.Error(), + }) + return + } + if strings.TrimSpace(body) == "" { + writeData(w, []interface{}{}) + return + } + writeRawData(w, body) +} + +// gatherTencentByType returns a typed JSON section (already JSON-encoded) for +// the given resource type. Returns "" with no error when the type matches but +// no resources exist; an error when the type is unsupported or the SDK call +// fails. +func gatherTencentByType(ctx context.Context, client *tencent.Client, resourceType string) (string, error) { + switch resourceType { + case "cvm", "instance", "instances": + return client.JSONCVMs(ctx) + case "vpc", "vpcs": + return client.JSONVPCs(ctx) + case "sg", "sgs", "security-group", "security-groups": + return client.JSONSecurityGroups(ctx) + case "mysql", "cdb": + return client.JSONMySQL(ctx) + case "postgres", "postgresql", "pg": + return client.JSONPostgres(ctx) + case "cos", "buckets", "bucket": + return client.JSONCOS(ctx) + case "tke", "k8s", "clusters", "kubernetes": + return client.JSONTKE(ctx) + case "clb", "lb", "lbs", "load-balancer", "load-balancers": + return client.JSONCLB(ctx) + case "eip", "eips", "address", "addresses": + return client.JSONEIP(ctx) + case "cbs", "disk", "disks", "volume", "volumes": + return client.JSONCBS(ctx) + case "ssl", "cert", "certs", "certificate", "certificates": + return client.JSONSSL(ctx) + case "cam", "iam", "user", "users": + return client.JSONCAM(ctx) + case "redis", "valkey": + return client.JSONRedis(ctx) + case "mongo", "mongodb": + return client.JSONMongoDB(ctx) + case "cynosdb", "tdsql-c", "tdsqlc": + return client.JSONCynosDB(ctx) + case "cdn", "cdn-domains": + return client.JSONCDN(ctx) + case "edgeone", "teo", "zones": + return client.JSONEdgeOne(ctx) + case "waf", "waf-hosts": + return client.JSONWAF(ctx) + case "antiddos", "ddos": + return client.JSONAntiDDoS(ctx) + case "nat", "nat-gateway", "natgateway": + return client.JSONNATGateways(ctx) + case "vpn", "vpn-gateway", "vpngateway": + return client.JSONVPNGateways(ctx) + case "ccn", "cloud-connect": + return client.JSONCCNs(ctx) + case "dc", "direct-connect", "directconnect": + return client.JSONDirectConnects(ctx) + case "monitor", "alarm", "alarms", "alarm-policy": + return client.JSONAlarmPolicies(ctx) + case "cls", "log", "logs", "log-topics": + return client.JSONCLSTopics(ctx) + case "cloudaudit", "audit", "tracks": + return client.JSONCloudAudit(ctx) + case "lighthouse", "lh": + return client.JSONLighthouses(ctx) + default: + return "", fmt.Errorf("unsupported resource type %q", resourceType) + } +} + +func (s *Server) handleTencentSGRules(w http.ResponseWriter, r *http.Request) { + sgID := strings.TrimSpace(r.PathValue("id")) + if sgID == "" { + writeError(w, http.StatusBadRequest, "missing_param", "sg id is required") + return + } + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + body, err := client.JSONSGRules(r.Context(), sgID) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentKubeconfig(w http.ResponseWriter, r *http.Request) { + clusterID := strings.TrimSpace(r.PathValue("cluster_id")) + if clusterID == "" { + writeError(w, http.StatusBadRequest, "missing_param", "cluster_id is required") + return + } + publicEndpoint := strings.EqualFold(r.URL.Query().Get("public"), "true") + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + kc, err := client.FetchKubeconfig(r.Context(), clusterID, publicEndpoint) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "data": map[string]string{ + "cluster_id": clusterID, + "kubeconfig": kc, + }, + }) +} + +func (s *Server) handleTencentTopology(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.TopologyJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentPublicExposure(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.PublicExposureScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +// tencentClient builds a Tencent client for this request. The region can be +// overridden per request via ?region=ap-jakarta; otherwise the daemon's +// default (resolved from config / env at startup) is used. Returns an +// *errInvalidRegion when the query param fails validation so handlers can +// distinguish a client-mistake (400) from a credential failure (401). +func (s *Server) tencentClient(r *http.Request) (*tencent.Client, error) { + creds := tencent.ResolveCredentials() + if region := strings.TrimSpace(r.URL.Query().Get("region")); region != "" { + if err := validateRegion(region); err != nil { + return nil, err + } + creds.Region = region + } + return tencent.NewClient(creds, s.cfg.Debug) +} + +// writeTencentClientErr emits the right status+code envelope for the two +// failure modes of tencentClient. Use this from every handler that calls +// tencentClient so the validation surface stays consistent. +func writeTencentClientErr(w http.ResponseWriter, err error) { + var ir *errInvalidRegion + if errors.As(err, &ir) { + writeError(w, http.StatusBadRequest, "invalid_region", err.Error()) + return + } + // Catch-all: most often the missing-credentials error from + // tencent.NewClient. The previous revision recursed here instead of + // writing the response, which stack-overflowed the process on the + // first credential-missing request — see PR #165 review by rafeegnash. + writeError(w, http.StatusUnauthorized, "tencent_credentials", err.Error()) +} + +// jsonValid is used by tests to assert that an internal JSON payload is +// well-formed; kept here so handlers and tests share one definition. +func jsonValid(s string) bool { + var v interface{} + return json.Unmarshal([]byte(s), &v) == nil +} + +func (s *Server) handleTencentCLBExposure(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.CLBExposureScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentIdleEIPs(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.IdleEIPScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentUnencryptedCBS(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.UnencryptedCBSScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentCertExpiry(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + days := 30 + if v := strings.TrimSpace(r.URL.Query().Get("days")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + days = n + } + } + body, err := client.CertExpiryScanJSON(r.Context(), days) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentCAMHygiene(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + body, err := client.CAMHygieneScanJSON(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentDBExposure(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.DBExposureScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentWAFCoverage(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + body, err := client.WAFCoverageScanJSON(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentAntiDDoSCoverage(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + body, err := client.AntiDDoSCoverageScanJSON(r.Context(), region) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentAuditCoverage(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + body, err := client.AuditLogCoverageScanJSON(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentCVMMetrics(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + metric := strings.TrimSpace(r.URL.Query().Get("metric")) + minutes := 60 + if v := strings.TrimSpace(r.URL.Query().Get("minutes")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + minutes = n + } + } + body, err := client.CVMMetricsJSON(r.Context(), region, metric, minutes) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +// handleTencentLighthouseMetrics mirrors handleTencentCVMMetrics but for the +// Lighthouse product line (namespace QCE/LIGHTHOUSE). Default metric is +// Cpu_Usage. Other valid names: Mem_Usage, Public_Bandwidth_In, +// Public_Bandwidth_Out, Internal_Bandwidth_In, Internal_Bandwidth_Out. +func (s *Server) handleTencentLighthouseMetrics(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + region := strings.TrimSpace(r.URL.Query().Get("region")) + metric := strings.TrimSpace(r.URL.Query().Get("metric")) + minutes := 60 + if v := strings.TrimSpace(r.URL.Query().Get("minutes")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + minutes = n + } + } + body, err := client.LighthouseMetricsJSON(r.Context(), region, metric, minutes) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentCostByProduct(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + month := strings.TrimSpace(r.URL.Query().Get("month")) + body, err := client.BillByProductJSON(r.Context(), month) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +func (s *Server) handleTencentCostResources(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + month := strings.TrimSpace(r.URL.Query().Get("month")) + top := 50 + if v := strings.TrimSpace(r.URL.Query().Get("top")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + top = n + } + } + body, err := client.BillResourceTopJSON(r.Context(), month, top) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +// handleTencentCostVouchers returns the account's vouchers plus a per-owner-UIN +// spend breakdown. Optional ?status= filters by Tencent's voucher-status enum +// (unUsed/used/delivered/cancel/overdue). +func (s *Server) handleTencentCostVouchers(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + status := strings.TrimSpace(r.URL.Query().Get("status")) + body, err := client.VouchersJSON(r.Context(), status) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +// handleTencentCostVoucherByOwner returns a month's voucher deduction grouped +// by the owner account UIN of each billed resource (?month=YYYY-MM). +func (s *Server) handleTencentCostVoucherByOwner(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + month := strings.TrimSpace(r.URL.Query().Get("month")) + body, err := client.VoucherByOwnerJSON(r.Context(), month) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + +// handleTencentExpiry walks every PREPAID-capable resource across the +// requested regions and returns items at or below the renewal threshold. +// Query params: ?regions=ap-x,ap-y &threshold=14 &manual_only=true &include_ssl=false. +// regions defaults to the server's configured region; threshold defaults +// to 30; manual_only defaults to true; include_ssl defaults to false. +// +// Every requested region is validated through the same regex SendRaw uses, +// so SSRF-shaped strings are rejected with 400 before any SDK call fires. +func (s *Server) handleTencentExpiry(w http.ResponseWriter, r *http.Request) { + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + + q := r.URL.Query() + var regions []string + if raw := strings.TrimSpace(q.Get("regions")); raw != "" { + for _, part := range strings.Split(raw, ",") { + region := strings.TrimSpace(part) + if region == "" { + continue + } + if err := validateRegion(region); err != nil { + writeError(w, http.StatusBadRequest, "invalid_region", err.Error()) + return + } + regions = append(regions, region) + } + } + + threshold := 30 + if v := strings.TrimSpace(q.Get("threshold")); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + writeError(w, http.StatusBadRequest, "invalid_threshold", "threshold must be a non-negative integer") + return + } + threshold = n + } + + manualOnly := true + if v := strings.TrimSpace(q.Get("manual_only")); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_manual_only", "manual_only must be true or false") + return + } + manualOnly = b + } + + includeSSL := false + if v := strings.TrimSpace(q.Get("include_ssl")); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_include_ssl", "include_ssl must be true or false") + return + } + includeSSL = b + } + + report, err := client.BuildExpiryReport(r.Context(), tencent.ExpiryReportOptions{ + Regions: regions, + ThresholdDays: threshold, + ManualOnly: manualOnly, + IncludeSSL: includeSSL, + }) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeData(w, report) +} + +// handleTencentCostVoucherUsage returns the deduction history for one voucher. +func (s *Server) handleTencentCostVoucherUsage(w http.ResponseWriter, r *http.Request) { + voucherID := strings.TrimSpace(r.PathValue("voucher_id")) + if voucherID == "" { + writeError(w, http.StatusBadRequest, "missing_param", "voucher_id is required") + return + } + client, err := s.tencentClient(r) + if err != nil { + writeTencentClientErr(w, err) + return + } + body, err := client.VoucherUsageJSON(r.Context(), voucherID) + if err != nil { + writeError(w, http.StatusBadGateway, "tencent_api_error", err.Error()) + return + } + writeRawData(w, body) +} + diff --git a/internal/api/routes_maker.go b/internal/api/routes_maker.go new file mode 100644 index 0000000..a6b3ec3 --- /dev/null +++ b/internal/api/routes_maker.go @@ -0,0 +1,163 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/maker" + "github.com/bgdnvk/clanker/internal/tencent" +) + +// applyRequest is the JSON body of POST /api/v1/maker/apply. +// +// `plan` is the same Plan JSON shape that `clanker ask --maker ...` emits. +// `destroyer` gates destructive operations (Terminate*, Delete*, Reset*) +// just like the --destroyer CLI flag. +type applyRequest struct { + Provider string `json:"provider"` + Plan json.RawMessage `json:"plan"` + Destroyer bool `json:"destroyer"` +} + +// applyResponse summarises an apply attempt. Output is the captured writer +// the executor used so the dashboard can render it like a CLI session. +type applyResponse struct { + Provider string `json:"provider"` + Status string `json:"status"` // "ok" or "error" + Output string `json:"output"` + Error string `json:"error,omitempty"` + Duration string `json:"duration"` + HistoryID int64 `json:"history_id,omitempty"` +} + +func (s *Server) handleMakerApply(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap + if err != nil { + writeError(w, http.StatusBadRequest, "read_body", err.Error()) + return + } + var req applyRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + if provider == "" { + provider = "tencent" // default — only one wired today + } + if provider != "tencent" { + writeError(w, http.StatusBadRequest, "unsupported_provider", "only provider=tencent is wired for apply via HTTP today") + return + } + if len(req.Plan) == 0 { + writeError(w, http.StatusBadRequest, "missing_plan", "plan is required") + return + } + + plan, err := maker.ParsePlan(string(req.Plan)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_plan", err.Error()) + return + } + if plan.Provider != "" && !strings.EqualFold(plan.Provider, "tencent") { + writeError(w, http.StatusBadRequest, "provider_mismatch", + "plan.provider="+plan.Provider+" does not match request provider=tencent") + return + } + + creds := tencent.ResolveCredentials() + if creds.SecretID == "" || creds.SecretKey == "" { + writeError(w, http.StatusUnauthorized, "tencent_credentials", + "server is missing tencent credentials (set TENCENTCLOUD_SECRET_ID/KEY or TENCENT_SECRET_ID/KEY before starting clanker server)") + return + } + + var buf bytes.Buffer + start := time.Now() + execErr := maker.ExecuteTencentPlan(r.Context(), plan, maker.ExecOptions{ + TencentSecretID: creds.SecretID, + TencentSecretKey: creds.SecretKey, + TencentRegion: creds.Region, + Writer: &buf, + Destroyer: req.Destroyer, + Debug: s.cfg.Debug, + }) + duration := time.Since(start).Round(time.Millisecond) + + // Build history record (regardless of success/failure so audit trail is + // complete). + rec := ApplyRecord{ + StartedAt: start, + Provider: provider, + Duration: duration.String(), + Destroyer: req.Destroyer, + CommandCount: len(plan.Commands), + DestructiveCount: countDestructiveCommands(plan.Commands), + Summary: strings.TrimSpace(plan.Summary), + Question: strings.TrimSpace(plan.Question), + Output: buf.String(), + } + if execErr != nil { + rec.Status = "error" + rec.Error = execErr.Error() + } else { + rec.Status = "ok" + } + if s.history != nil { + rec = s.history.append(rec) + } + + resp := applyResponse{ + Provider: provider, + Output: buf.String(), + Duration: duration.String(), + Status: rec.Status, + HistoryID: rec.ID, + } + if execErr != nil { + resp.Error = execErr.Error() + } + writeJSON(w, http.StatusOK, map[string]interface{}{"data": resp}) +} + +// handleMakerHistory returns the in-memory ring buffer of past applies +// (newest first). Supports ?limit=N to cap the response size. +func (s *Server) handleMakerHistory(w http.ResponseWriter, r *http.Request) { + limit := 0 + if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if s.history == nil { + writeData(w, []ApplyRecord{}) + return + } + writeData(w, s.history.list(limit)) +} + +// countDestructiveCommands counts plan commands whose Tencent action is +// gated by --destroyer in the executor. Delegates the classification to +// maker.IsTencentDestructive so the displayed count never drifts away from +// what the executor's safety gate actually enforces — the previous local +// prefix denylist would have undercounted any CAM mutation like AddUser / +// CreateAccessKey / AttachUserPolicy (none of which match the old +// Terminate|Delete|Destroy|Reset|Release|Discontinue prefixes). +func countDestructiveCommands(cmds []maker.Command) int { + n := 0 + for _, c := range cmds { + if len(c.Args) < 3 { + continue + } + action := c.Args[2] + if maker.IsTencentDestructive(action) { + n++ + } + } + return n +} diff --git a/internal/api/routes_plan.go b/internal/api/routes_plan.go new file mode 100644 index 0000000..adc36b8 --- /dev/null +++ b/internal/api/routes_plan.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/maker" + "github.com/spf13/viper" +) + +// planRequest is the JSON body of POST /api/v1/maker/plan. +type planRequest struct { + Provider string `json:"provider"` + Question string `json:"question"` + Destroyer bool `json:"destroyer"` +} + +// planResponse wraps the generated plan plus diagnostic metadata so the +// dashboard can show timings + which model produced the plan. +type planResponse struct { + Provider string `json:"provider"` + Plan json.RawMessage `json:"plan"` + Model string `json:"model,omitempty"` + AIProfile string `json:"ai_profile,omitempty"` + Duration string `json:"duration"` +} + +// handleMakerPlan generates a maker plan via the configured AI provider. +// Mirrors the cmd/ask.go --maker path but runs entirely server-side so the +// dashboard can drive the full agent loop in browser. +func (s *Server) handleMakerPlan(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) // 64 KiB cap for a question + if err != nil { + writeError(w, http.StatusBadRequest, "read_body", err.Error()) + return + } + var req planRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + if provider == "" { + provider = "tencent" + } + if provider != "tencent" { + writeError(w, http.StatusBadRequest, "unsupported_provider", "only provider=tencent is wired for plan generation via HTTP today") + return + } + if strings.TrimSpace(req.Question) == "" { + writeError(w, http.StatusBadRequest, "missing_question", "question is required") + return + } + + // Resolve AI provider from viper (cmd/server.go pushes flag values in + // before api.New so they're available here). + aiProfile := strings.TrimSpace(viper.GetString("ai.default_provider")) + if aiProfile == "" { + aiProfile = "openai" + } + model := strings.TrimSpace(viper.GetString(fmt.Sprintf("ai.providers.%s.model", aiProfile))) + + apiKey := resolveAPIKeyForProvider(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, s.cfg.Debug, aiProfile) + + prompt := maker.TencentPlanPromptWithMode(req.Question, req.Destroyer) + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + start := time.Now() + raw, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + writeError(w, http.StatusBadGateway, "llm_error", err.Error()) + return + } + cleaned := aiClient.CleanJSONResponse(raw) + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + writeError(w, http.StatusBadGateway, "empty_plan", "LLM returned an empty plan") + return + } + + // Validate by parsing — we don't return ParsedPlan because the + // dashboard wants the raw JSON for display in the editor. + if _, parseErr := maker.ParsePlan(cleaned); parseErr != nil { + // 422 (not 200) so clients can branch on the status code without + // having to inspect the body for a "warning" key. The raw cleaned + // text and parse error are still returned so the dashboard can + // show them in the plan editor for hand-correction. + writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{ + "error": map[string]string{ + "code": "unparseable_plan", + "message": "generated plan did not parse cleanly: " + parseErr.Error(), + }, + "data": planResponse{ + Provider: provider, + Plan: json.RawMessage(cleaned), + Model: model, + AIProfile: aiProfile, + Duration: time.Since(start).Round(time.Millisecond).String(), + }, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "data": planResponse{ + Provider: provider, + Plan: json.RawMessage(cleaned), + Model: model, + AIProfile: aiProfile, + Duration: time.Since(start).Round(time.Millisecond).String(), + }, + }) +} + +// resolveAPIKeyForProvider picks the right viper key for the given provider. +// Mirrors the resolution that cmd/ask.go does so the server reads keys from +// the same config slots. +func resolveAPIKeyForProvider(provider string) string { + switch provider { + case "openai": + return viper.GetString("ai.providers.openai.api_key") + case "gemini", "gemini-api": + return viper.GetString("ai.providers.gemini-api.api_key") + case "anthropic": + return viper.GetString("ai.providers.anthropic.api_key") + case "cohere": + return viper.GetString("ai.providers.cohere.api_key") + case "deepseek": + return viper.GetString("ai.providers.deepseek.api_key") + case "minimax": + return viper.GetString("ai.providers.minimax.api_key") + default: + return viper.GetString("ai.api_key") + } +} diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go new file mode 100644 index 0000000..283eb45 --- /dev/null +++ b/internal/api/routes_test.go @@ -0,0 +1,135 @@ +package api + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// TestWriteTencentClientErr_NoRecursion is the regression test for the +// infinite-recursion bug rafeegnash caught in PR #165 review. Before the +// fix, the catch-all branch of writeTencentClientErr called itself +// instead of writeError — the first credential-missing request crashed +// the process with `fatal error: stack overflow`. This test exercises a +// Tencent handler with no credentials set and asserts we get a clean 401 +// envelope back. +func TestWriteTencentClientErr_NoRecursion(t *testing.T) { + // Scrub every credential surface tencent.ResolveCredentials reads so + // the path that previously recursed is hit. + scrubTencentCreds(t) + + srv := New(Config{Token: "test-token", Insecure: false}, log.New(io.Discard, "", 0)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tencent/regions", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d (body=%q)", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, `"tencent_credentials"`) { + t.Errorf("expected error code 'tencent_credentials' in body, got %q", body) + } +} + +// TestWriteTencentClientErr_InvalidRegion locks in the other branch — a +// validation failure should return 400 with `invalid_region`, not the +// catch-all 401. Cheap to assert and exercises the typed errors.As path. +func TestWriteTencentClientErr_InvalidRegion(t *testing.T) { + // Set fake creds so we get past tencent.NewClient and reach the + // region validator inside tencentClient. + scrubTencentCreds(t) + t.Setenv("TENCENTCLOUD_SECRET_ID", "fake-id") + t.Setenv("TENCENTCLOUD_SECRET_KEY", "fake-key") + t.Setenv("TENCENTCLOUD_REGION", "ap-singapore") + + srv := New(Config{Token: "test-token", Insecure: false}, log.New(io.Discard, "", 0)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tencent/regions?region=evil%20region", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + srv.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body=%q)", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, `"invalid_region"`) { + t.Errorf("expected error code 'invalid_region' in body, got %q", body) + } +} + +// TestWriteTencentClientErr_Direct asserts the helper itself returns the +// right shape for each branch without going through a handler — the +// minimum the recursion fix had to satisfy. +func TestWriteTencentClientErr_Direct(t *testing.T) { + t.Run("invalid region branch", func(t *testing.T) { + rec := httptest.NewRecorder() + writeTencentClientErr(rec, &errInvalidRegion{value: "bad"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid-region: want 400, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"invalid_region"`) { + t.Errorf("missing invalid_region code: %s", rec.Body.String()) + } + }) + t.Run("catch-all branch", func(t *testing.T) { + rec := httptest.NewRecorder() + writeTencentClientErr(rec, errors.New("creds missing")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("catch-all: want 401, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"tencent_credentials"`) { + t.Errorf("missing tencent_credentials code: %s", rec.Body.String()) + } + }) + t.Run("wrapped invalid region survives errors.As", func(t *testing.T) { + // tencentClient wraps errInvalidRegion with %w — the typed As + // path needs to keep working through the wrap. + rec := httptest.NewRecorder() + wrapped := fmt.Errorf("client init: %w", &errInvalidRegion{value: "bad"}) + writeTencentClientErr(rec, wrapped) + if rec.Code != http.StatusBadRequest { + t.Fatalf("wrapped invalid-region: want 400, got %d", rec.Code) + } + }) +} + +// scrubTencentCreds wipes every credential surface ResolveCredentials reads. +// Restoration is automatic via t.Setenv / viper.Reset, but we still need to +// strip values that may be present in the developer's shell env. +func scrubTencentCreds(t *testing.T) { + t.Helper() + for _, k := range []string{ + "TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID", + "TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY", + "TENCENTCLOUD_REGION", "TENCENT_REGION", + } { + prev, had := os.LookupEnv(k) + os.Unsetenv(k) + if had { + t.Cleanup(func() { os.Setenv(k, prev) }) + } + } + // viper.GetString reads in-memory configuration — clear the keys + // ResolveCredentials looks at so this test isn't perturbed by a + // previous test that called viper.Set. + for _, k := range []string{"tencent.secret_id", "tencent.secret_key", "tencent.region"} { + viper.Set(k, "") + } + t.Cleanup(func() { + for _, k := range []string{"tencent.secret_id", "tencent.secret_key", "tencent.region"} { + viper.Set(k, "") + } + }) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..684d4e3 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,145 @@ +// Package api hosts the HTTP API layer that wraps the clanker agent. +// +// Phase 4 (per the planning doc) — exposes Tencent Cloud inventory and +// helper endpoints as JSON so the React/Vite dashboard (Phase 5+) can +// drive the agent without shelling out to the CLI. +// +// The server uses stdlib net/http (Go 1.22+ pattern routing) — no third +// party router needed at this scale. +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +// Config controls how the HTTP server is bound and how requests are +// authorised. A missing Token aborts startup unless Insecure is set +// explicitly — see Server.Run. +type Config struct { + Addr string // listen address, e.g. ":8080" + Token string // bearer token; required unless Insecure is true + Insecure bool // explicit opt-in to running without auth; refused otherwise + CORSOrigin string // value for Access-Control-Allow-Origin; defaults to http://localhost:4173 (the bundled dashboard) + ReadTimeout time.Duration + WriteTimeout time.Duration + Debug bool +} + +// Server wraps an *http.Server plus the routes the API exposes. Build it +// with New and start with Run; Run blocks until ctx is cancelled. +type Server struct { + cfg Config + mux *http.ServeMux + logger *log.Logger + started time.Time + history *history +} + +// New constructs a Server with the standard route set. Call Run to start. +func New(cfg Config, logger *log.Logger) *Server { + if cfg.Addr == "" { + cfg.Addr = ":8080" + } + if cfg.CORSOrigin == "" { + // Default to the local dashboard origin only. The previous "*" + // default was too permissive — any page could read responses. + // Bearer auth on the Authorization header mitigates CSRF (browsers + // don't auto-send custom headers cross-origin), but allowing any + // origin to *read* responses still leaks data to a hostile page if + // the user pastes a token there. Operators who actually need a + // public dashboard pass --cors-origin explicitly. + cfg.CORSOrigin = "http://localhost:4173" + } + if cfg.ReadTimeout == 0 { + cfg.ReadTimeout = 30 * time.Second + } + if cfg.WriteTimeout == 0 { + cfg.WriteTimeout = 90 * time.Second + } + if logger == nil { + logger = log.Default() + } + s := &Server{cfg: cfg, mux: http.NewServeMux(), logger: logger, started: time.Now(), history: newHistory()} + s.registerRoutes() + return s +} + +// Run starts the HTTP server and blocks until ctx is cancelled or +// ListenAndServe returns an error. +func (s *Server) Run(ctx context.Context) error { + if strings.TrimSpace(s.cfg.Token) == "" { + if !s.cfg.Insecure { + // Refusing to start prevents the most common production + // footgun: leaving the server reachable on a public IP with + // POST /api/v1/maker/apply (which can mutate cloud + // resources) unauthenticated. Pass --insecure explicitly to + // override for trusted-network setups. + return fmt.Errorf("refusing to start without a bearer token: pass --token, set CLANKER_API_TOKEN, or pass --insecure to opt in to running without auth (NOT recommended on a public address)") + } + s.logger.Println("[api] WARNING: --insecure set, no token — server is OPEN; /api/v1/maker/apply will accept unauthenticated mutations.") + } + srv := &http.Server{ + Addr: s.cfg.Addr, + Handler: s.middleware(s.mux), + ReadTimeout: s.cfg.ReadTimeout, + WriteTimeout: s.cfg.WriteTimeout, + } + errCh := make(chan error, 1) + go func() { + s.logger.Printf("[api] listening on %s", s.cfg.Addr) + errCh <- srv.ListenAndServe() + }() + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + return ctx.Err() + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + return err + } +} + +// writeJSON encodes v as a JSON response with the given status code and the +// canonical `Content-Type: application/json` header. Used by every handler so +// the response shape stays uniform. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("[api] writeJSON: %v", err) + } +} + +// writeError responds with a uniform error envelope so frontend code can +// branch on `error.code` instead of parsing free-form messages. +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]interface{}{ + "error": map[string]string{ + "code": code, + "message": message, + }, + }) +} + +// writeData wraps a successful response in `{ "data": ... }` envelope. +func writeData(w http.ResponseWriter, v interface{}) { + writeJSON(w, http.StatusOK, map[string]interface{}{"data": v}) +} + +// writeRaw is used for endpoints whose source is already JSON-encoded +// (e.g. Tencent context gather funcs), so we don't double-encode. +func writeRawData(w http.ResponseWriter, rawJSON string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, `{"data":%s}`, rawJSON) +} diff --git a/internal/maker/exec.go b/internal/maker/exec.go index 6b02533..2192adc 100644 --- a/internal/maker/exec.go +++ b/internal/maker/exec.go @@ -248,6 +248,11 @@ type ExecOptions struct { VerdaClientSecret string VerdaProjectID string + // Tencent Cloud options + TencentSecretID string + TencentSecretKey string + TencentRegion string + CheckpointKey string DisableDurableCheckpoint bool @@ -1449,6 +1454,41 @@ func jsonPathString(obj any, path string) (string, bool) { } idxStr := strings.TrimSpace(rest[1:end]) rest = rest[end+1:] + + // Wildcard: collect the chosen field from EVERY element of the + // current array and bind as a JSON array literal. The remainder + // of the path (e.g. ".InstanceId") is applied to each element. + // Returned as: ["ins-1","ins-2"] — drop into a position that + // accepts a JSON array, e.g. "InstanceIds": . + if idxStr == "*" { + arr, ok := cur.([]any) + if !ok { + return "", false + } + remainder := strings.TrimPrefix(path[len(seg):], ".") + strings.TrimPrefix(rest, ".") + remainder = strings.TrimSpace(remainder) + var out []any + for _, item := range arr { + if remainder == "" { + out = append(out, item) + continue + } + sub, ok := jsonPathRaw(item, remainder) + if !ok { + continue + } + out = append(out, sub) + } + if len(out) == 0 { + return "", false + } + b, err := json.Marshal(out) + if err != nil { + return "", false + } + return string(b), true + } + idx, err := strconv.Atoi(idxStr) if err != nil { return "", false @@ -1490,6 +1530,68 @@ func jsonPathString(obj any, path string) (string, bool) { } } +// jsonPathRaw is the value-returning counterpart of jsonPathString — used by +// the [*] wildcard branch to walk into each array element with a remainder +// path. Returns the matched value (string/number/bool/object) as `any` plus +// an ok flag. It does NOT itself recurse into wildcards; the wildcard is +// expanded in the caller. +func jsonPathRaw(obj any, path string) (any, bool) { + path = strings.TrimSpace(path) + if path == "" { + return obj, true + } + path = strings.TrimPrefix(path, "$") + path = strings.TrimPrefix(path, ".") + + cur := obj + for len(path) > 0 { + seg := path + if i := strings.Index(seg, "."); i >= 0 { + seg = seg[:i] + } + name := seg + rest := "" + if i := strings.Index(name, "["); i >= 0 { + rest = name[i:] + name = name[:i] + } + name = strings.TrimSpace(name) + if name != "" { + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = m[name] + if !ok { + return nil, false + } + } + for strings.HasPrefix(rest, "[") { + end := strings.Index(rest, "]") + if end < 0 { + return nil, false + } + idxStr := strings.TrimSpace(rest[1:end]) + rest = rest[end+1:] + idx, err := strconv.Atoi(idxStr) + if err != nil { + return nil, false + } + arr, ok := cur.([]any) + if !ok || idx < 0 || idx >= len(arr) { + return nil, false + } + cur = arr[idx] + } + if len(path) == len(seg) { + path = "" + } else { + path = strings.TrimPrefix(path[len(seg):], ".") + } + } + return cur, true +} + func applyPlanBindings(args []string, bindings map[string]string) []string { if len(args) == 0 || len(bindings) == 0 { return args diff --git a/internal/maker/exec_tencent.go b/internal/maker/exec_tencent.go new file mode 100644 index 0000000..652ca89 --- /dev/null +++ b/internal/maker/exec_tencent.go @@ -0,0 +1,243 @@ +package maker + +import ( + "context" + "fmt" + "strings" + + "github.com/bgdnvk/clanker/internal/tencent" +) + +// ExecuteTencentPlan executes a Tencent Cloud maker plan. Commands take the +// form ["tencent-api", "", "", "", ""] +// and are dispatched to the shared tencent.Client.SendRaw which uses the +// Tencent SDK's generic CommonRequest for signing + transport. +// +// Mirrors the Verda executor's shape (no CLI dependency, strict arg +// validation, destructive ops gated by --destroyer). Idempotent error codes +// like "ResourceAlreadyExists" are treated as soft successes so re-running a +// partially-applied plan converges. +func ExecuteTencentPlan(ctx context.Context, plan *Plan, opts ExecOptions) error { + if plan == nil { + return fmt.Errorf("nil plan") + } + if opts.Writer == nil { + return fmt.Errorf("missing output writer") + } + if opts.TencentSecretID == "" || opts.TencentSecretKey == "" { + return fmt.Errorf("missing tencent credentials (set tencent.secret_id / tencent.secret_key, TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY, or TENCENT_SECRET_ID / TENCENT_SECRET_KEY)") + } + + creds := tencent.Credentials{ + SecretID: opts.TencentSecretID, + SecretKey: opts.TencentSecretKey, + Region: opts.TencentRegion, + } + client, err := tencent.NewClient(creds, opts.Debug) + if err != nil { + return fmt.Errorf("build tencent client: %w", err) + } + + bindings := make(map[string]string) + priorOutputs := make([]string, 0, len(plan.Commands)) + + for idx, cmdSpec := range plan.Commands { + args := make([]string, 0, len(cmdSpec.Args)) + args = append(args, cmdSpec.Args...) + args = applyPlanBindings(args, bindings) + + if err := validateTencentCommand(args, opts.Destroyer); err != nil { + return fmt.Errorf("command %d rejected: %w", idx+1, err) + } + if hasUnresolvedPlaceholders(args) { + unresolved := extractUnresolvedPlaceholders(args) + declared := make([]string, 0, len(bindings)) + for k := range bindings { + declared = append(declared, "<"+k+">") + } + return fmt.Errorf( + "command %d has unresolved placeholders after substitutions: %s. "+ + "Bound so far: [%s]. Likely cause: the JSONPath in an earlier command's "+ + "`produces` didn't match (object/array path used where a scalar/array was "+ + "expected, or the field name is wrong). For per-instance Cloud Monitor "+ + "queries this can't be chained — use a discovery-only plan and view the "+ + "dashboard's Monitoring page", + idx+1, + strings.Join(unresolved, ", "), + strings.Join(declared, ", "), + ) + } + + // filter verb — runs client-side over a prior command's output. + // Does not touch Tencent. Output is appended to priorOutputs so a + // later filter can chain off it, and bindings get applied just like + // for Tencent commands so produces can extract from filtered items. + if strings.EqualFold(strings.TrimSpace(args[0]), "filter") { + _, _ = fmt.Fprintf(opts.Writer, "[maker] running %d/%d: filter source=%s path=%s field=%s op=%s value=%s\n", + idx+1, len(plan.Commands), args[1], args[2], args[3], args[4], args[5]) + body, err := executeFilter(args, priorOutputs) + if err != nil { + return fmt.Errorf("command %d (filter) failed: %w", idx+1, err) + } + if strings.TrimSpace(body) != "" { + _, _ = fmt.Fprintln(opts.Writer, body) + } + priorOutputs = append(priorOutputs, body) + learnPlanBindingsFromProduces(cmdSpec.Produces, body, bindings) + continue + } + + service := strings.ToLower(strings.TrimSpace(args[1])) + action := strings.TrimSpace(args[2]) + region := strings.TrimSpace(args[3]) + params := "" + if len(args) >= 5 { + params = args[4] + } + + _, _ = fmt.Fprintf(opts.Writer, "[maker] running %d/%d: tencent-api %s.%s region=%s\n", + idx+1, len(plan.Commands), service, action, region) + if opts.Debug && params != "" { + _, _ = fmt.Fprintf(opts.Writer, "[maker] params: %s\n", params) + } + + body, err := client.SendRaw(service, action, region, params) + if err != nil { + if isTencentSoftFailure(err) { + _, _ = fmt.Fprintf(opts.Writer, "[maker] soft failure (treating as success): %v\n", err) + priorOutputs = append(priorOutputs, "") + continue + } + return fmt.Errorf("tencent command %d failed (%s.%s): %w", idx+1, service, action, err) + } + priorOutputs = append(priorOutputs, body) + + if strings.TrimSpace(body) != "" { + _, _ = fmt.Fprintln(opts.Writer, body) + } + learnPlanBindingsFromProduces(cmdSpec.Produces, body, bindings) + } + + return nil +} + +// validateTencentCommand rejects anything that isn't a well-formed tencent-api +// call. Any action NOT in the read-only verb allowlist (Describe, Get, List, +// Query, Lookup, Search, Check, Inquiry — see IsTencentDestructive) is gated +// behind --destroyer, matching the policy applied to every other provider. +func validateTencentCommand(args []string, allowDestructive bool) error { + if len(args) == 0 { + return fmt.Errorf("empty command") + } + verb := strings.ToLower(strings.TrimSpace(args[0])) + + // The filter verb is a client-side post-processor — it doesn't hit + // Tencent and has its own arg shape, so validate separately. + if verb == "filter" { + return validateFilterCommand(args) + } + + if len(args) < 4 { + return fmt.Errorf("tencent plan commands require at least 4 args [verb, service, action, region], got %d", len(args)) + } + if len(args) > 5 { + return fmt.Errorf("tencent plan commands take at most 5 args [verb, service, action, region, params], got %d", len(args)) + } + + if verb != "tencent-api" { + return fmt.Errorf("only tencent-api and filter verbs are supported (got %q)", args[0]) + } + + service := strings.ToLower(strings.TrimSpace(args[1])) + if service == "" { + return fmt.Errorf("service is required") + } + + action := strings.TrimSpace(args[2]) + if action == "" { + return fmt.Errorf("action is required") + } + + region := strings.TrimSpace(args[3]) + if region == "" { + return fmt.Errorf("region is required") + } + + for _, a := range args { + if strings.ContainsAny(a, "\n\r") { + return fmt.Errorf("newlines in args are not allowed") + } + } + + if !allowDestructive && IsTencentDestructive(action) { + return fmt.Errorf("destructive tencent operation blocked (use --destroyer to allow): %s.%s", service, action) + } + return nil +} + +// readOnlyVerbPrefixes are the Tencent action-name prefixes that +// indicate a read-only operation. Anything that does NOT match one of +// these is treated as destructive and gated behind --destroyer. +// +// Tencent's naming conventions for reads: +// - Describe* — canonical "read inventory" verb (DescribeInstances) +// - Get* — point lookups (GetMonitorData, GetBucket) +// - List* — enumerate CAM/COS resources +// - Query* — rare read variant +// - Lookup* — LookupEvents (CloudAudit) +// - Search* — SearchTopics (CLS) +// - Check* — idempotent existence/availability checks +// - Inquiry* — InquiryPriceX (price quotes, no side effects) +// +// Anything else — Create*, Run*, Add*, Modify*, Update*, Set*, Enable*, +// Disable*, Bind*, Unbind*, Associate*, Allocate*, Assign*, Apply*, +// Terminate*, Delete*, Destroy*, Reset*, Release*, Discontinue* etc. — +// can mutate state, costs money, or compromises security and must be +// explicitly approved via --destroyer. +var readOnlyVerbPrefixes = []string{ + "Describe", "Get", "List", "Query", "Lookup", "Search", "Check", "Inquiry", +} + +// IsTencentDestructive returns true when the action is NOT in the +// read-only verb allowlist (Describe, Get, List, Query, Lookup, Search, +// Check, Inquiry). Exported so the HTTP audit-record layer can count +// destructive commands using the SAME classifier the executor's safety +// gate uses — otherwise the displayed count drifts away from what's +// actually enforced. +// +// Was previously a prefix DENY-list (Terminate|Delete|Destroy|Reset| +// Release|Discontinue) which silently permitted CAM mutations like +// AddUser, CreateAccessKey, and AttachUserPolicy — none of which match +// those verbs but absolutely require --destroyer approval. Flipping to +// an allowlist is fail-safe: any unrecognized verb is treated as +// destructive by default. +func IsTencentDestructive(action string) bool { + for _, prefix := range readOnlyVerbPrefixes { + if strings.HasPrefix(action, prefix) { + return false + } + } + return true +} + +// isTencentSoftFailure returns true for error codes that indicate the desired +// state already exists, which is safe to treat as a no-op success during +// idempotent re-applies. +func isTencentSoftFailure(err error) bool { + if err == nil { + return false + } + msg := err.Error() + for _, s := range []string{ + "ResourceInUse", + "ResourceAlreadyExists", + "AlreadyExists", + "InvalidParameterValue.Duplicate", + "InvalidVpc.Duplicate", + } { + if strings.Contains(msg, s) { + return true + } + } + return false +} diff --git a/internal/maker/exec_tencent_filter.go b/internal/maker/exec_tencent_filter.go new file mode 100644 index 0000000..d42f897 --- /dev/null +++ b/internal/maker/exec_tencent_filter.go @@ -0,0 +1,235 @@ +package maker + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// Caps on the `matches` operator. Go's regexp engine is RE2-based and runs in +// linear time — `(a+)+$` style patterns don't catastrophically backtrack +// like they would in PCRE/JS — so a pure ReDoS isn't really possible here. +// We still cap pattern length and add a wall-clock deadline as defense in +// depth: the filter values originate from LLM plan output and travel through +// POST /api/v1/maker/apply, so any future engine swap or pathologically +// memory-heavy pattern shouldn't be able to stall the request. +const ( + maxFilterPatternLen = 256 + filterMatchTimeout = 100 * time.Millisecond +) + +// validFilterOps lists the operators the filter verb accepts. Kept small and +// explicit so the LLM has a finite set to choose from. +var validFilterOps = map[string]bool{ + ">": true, + "<": true, + ">=": true, + "<=": true, + "==": true, + "!=": true, + "contains": true, + "startsWith": true, + "matches": true, +} + +// validateFilterCommand checks the shape of a filter-verb command. Schema: +// +// ["filter", "", "", "", "", ""] +// +// sourceIdx is either "$prev" or a 1-based numeric index. +// arrayPath is a JSONPath that, when applied to the source body, resolves to +// an array of objects. field is a JSONPath inside each element. op is one of +// validFilterOps. value is parsed as a number when op is numeric. +func validateFilterCommand(args []string) error { + if len(args) != 6 { + return fmt.Errorf("filter verb requires exactly 6 args [filter, , , , , ], got %d", len(args)) + } + if strings.TrimSpace(args[2]) == "" { + return fmt.Errorf("filter arrayPath is required (e.g. $.Response.InstanceSet)") + } + if strings.TrimSpace(args[3]) == "" { + return fmt.Errorf("filter field is required (e.g. Memory or InstanceState)") + } + if !validFilterOps[args[4]] { + ops := make([]string, 0, len(validFilterOps)) + for k := range validFilterOps { + ops = append(ops, k) + } + return fmt.Errorf("filter op %q invalid; must be one of: %s", args[4], strings.Join(ops, " ")) + } + src := strings.TrimSpace(args[1]) + if src == "$prev" { + return nil + } + if n, err := strconv.Atoi(src); err != nil || n < 1 { + return fmt.Errorf("filter sourceIdx %q invalid; must be a 1-based index or $prev", args[1]) + } + return nil +} + +// executeFilter runs a filter command against priorOutputs and returns the +// filtered subset as a JSON document. The shape is: +// +// { "matched": , "total_in": , "field": "...", "op": "...", "value": "...", "items": [...] } +// +// "items" is the subset of elements from the resolved array that satisfied +// the predicate. The full input is NOT echoed back to keep output small. +func executeFilter(args []string, priorOutputs []string) (string, error) { + if err := validateFilterCommand(args); err != nil { + return "", err + } + sourceIdx := strings.TrimSpace(args[1]) + arrayPath := strings.TrimSpace(args[2]) + field := strings.TrimSpace(args[3]) + op := args[4] + value := args[5] + + // Resolve source body. + var sourceBody string + if sourceIdx == "$prev" { + if len(priorOutputs) == 0 { + return "", fmt.Errorf("filter sourceIdx=$prev but there are no prior commands") + } + sourceBody = priorOutputs[len(priorOutputs)-1] + } else { + n, _ := strconv.Atoi(sourceIdx) + if n < 1 || n > len(priorOutputs) { + return "", fmt.Errorf("filter sourceIdx=%d out of range (1..%d prior commands)", n, len(priorOutputs)) + } + sourceBody = priorOutputs[n-1] + } + if strings.TrimSpace(sourceBody) == "" { + return "", fmt.Errorf("filter source command produced no output to filter") + } + + // Parse source. + var raw any + if err := json.Unmarshal([]byte(sourceBody), &raw); err != nil { + return "", fmt.Errorf("filter source body is not valid JSON: %w", err) + } + + // Resolve arrayPath. Trim a trailing [*] if present (LLM often appends it). + cleanPath := strings.TrimSuffix(strings.TrimSpace(arrayPath), "[*]") + resolved, ok := jsonPathRaw(raw, cleanPath) + if !ok { + return "", fmt.Errorf("filter arrayPath %q did not resolve in the source body", arrayPath) + } + items, ok := resolved.([]any) + if !ok { + return "", fmt.Errorf("filter arrayPath %q resolved to a non-array (got %T) — point at an array of items like $.Response.InstanceSet", arrayPath, resolved) + } + + // Filter. + matched := make([]any, 0, len(items)) + for _, item := range items { + v, ok := jsonPathRaw(item, field) + if !ok { + continue + } + if filterMatch(v, op, value) { + matched = append(matched, item) + } + } + + out := struct { + Matched int `json:"matched"` + TotalIn int `json:"total_in"` + Field string `json:"field"` + Op string `json:"op"` + Value string `json:"value"` + Items []any `json:"items"` + }{ + Matched: len(matched), + TotalIn: len(items), + Field: field, + Op: op, + Value: value, + Items: matched, + } + b, err := json.Marshal(out) + if err != nil { + return "", fmt.Errorf("filter result marshal: %w", err) + } + return string(b), nil +} + +// filterMatch evaluates one predicate. Numeric operators auto-convert string +// values that parse cleanly; non-parseable comparisons return false rather +// than erroring so a single bad item doesn't poison the run. +func filterMatch(v any, op, value string) bool { + switch op { + case ">", "<", ">=", "<=": + a, ok1 := toFloat(v) + b, ok2 := toFloat(value) + if !ok1 || !ok2 { + return false + } + switch op { + case ">": + return a > b + case "<": + return a < b + case ">=": + return a >= b + case "<=": + return a <= b + } + case "==": + return fmt.Sprint(v) == value + case "!=": + return fmt.Sprint(v) != value + case "contains": + s, ok := v.(string) + return ok && strings.Contains(s, value) + case "startsWith": + s, ok := v.(string) + return ok && strings.HasPrefix(s, value) + case "matches": + s, ok := v.(string) + if !ok || len(value) > maxFilterPatternLen { + return false + } + re, err := regexp.Compile(value) + if err != nil { + return false + } + // Buffered channel + select-with-timeout: if MatchString ever + // hangs the abandoned goroutine still has somewhere to write, + // so we don't leak it forever — it just exits when GC frees the + // channel after the result drains. With Go's RE2 the timeout + // path is effectively unreachable in practice. + done := make(chan bool, 1) + go func() { done <- re.MatchString(s) }() + select { + case match := <-done: + return match + case <-time.After(filterMatchTimeout): + return false + } + } + return false +} + +func toFloat(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case int: + return float64(x), true + case int64: + return float64(x), true + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil { + return f, true + } + case bool: + if x { + return 1, true + } + return 0, true + } + return 0, false +} diff --git a/internal/maker/exec_tencent_filter_test.go b/internal/maker/exec_tencent_filter_test.go new file mode 100644 index 0000000..5bff494 --- /dev/null +++ b/internal/maker/exec_tencent_filter_test.go @@ -0,0 +1,205 @@ +package maker + +import ( + "strings" + "testing" +) + +// TestValidateFilterCommand covers the security-relevant validation surface +// the maker plan executor uses to reject malformed filter verbs before they +// reach jsonPathRaw / filterMatch. Each case asserts the success/failure +// shape AND that the error message names the offending field — the LLM uses +// that text to self-correct on the next plan attempt. +func TestValidateFilterCommand(t *testing.T) { + cases := []struct { + name string + args []string + wantErr bool + errSubstr string // substring the error message must contain (when wantErr) + }{ + // arg count + { + name: "too few args", + args: []string{"filter", "$prev", "$.x", "name", "=="}, + wantErr: true, + errSubstr: "requires exactly 6 args", + }, + { + name: "too many args", + args: []string{"filter", "$prev", "$.x", "name", "==", "v", "extra"}, + wantErr: true, + errSubstr: "requires exactly 6 args", + }, + // arrayPath / field empty + { + name: "empty arrayPath", + args: []string{"filter", "$prev", "", "name", "==", "v"}, + wantErr: true, + errSubstr: "arrayPath is required", + }, + { + name: "whitespace arrayPath", + args: []string{"filter", "$prev", " ", "name", "==", "v"}, + wantErr: true, + errSubstr: "arrayPath is required", + }, + { + name: "empty field", + args: []string{"filter", "$prev", "$.x", "", "==", "v"}, + wantErr: true, + errSubstr: "field is required", + }, + // op enum + { + name: "unknown op", + args: []string{"filter", "$prev", "$.x", "name", "===", "v"}, + wantErr: true, + errSubstr: "filter op", + }, + { + name: "case-sensitive op", + args: []string{"filter", "$prev", "$.x", "name", "Contains", "v"}, + wantErr: true, + errSubstr: "filter op", + }, + // sourceIdx + { + name: "sourceIdx zero", + args: []string{"filter", "0", "$.x", "name", "==", "v"}, + wantErr: true, + errSubstr: "sourceIdx", + }, + { + name: "sourceIdx negative", + args: []string{"filter", "-1", "$.x", "name", "==", "v"}, + wantErr: true, + errSubstr: "sourceIdx", + }, + { + name: "sourceIdx non-numeric non-prev", + args: []string{"filter", "abc", "$.x", "name", "==", "v"}, + wantErr: true, + errSubstr: "sourceIdx", + }, + // happy paths + { + name: "valid $prev", + args: []string{"filter", "$prev", "$.x", "name", "==", "v"}, + wantErr: false, + }, + { + name: "valid numeric idx", + args: []string{"filter", "1", "$.x", "name", "==", "v"}, + wantErr: false, + }, + { + name: "valid large numeric idx", + args: []string{"filter", "99", "$.Response.X", "Memory", ">=", "8"}, + wantErr: false, + }, + // every op accepted + {name: "op >", args: []string{"filter", "$prev", "$.x", "n", ">", "1"}, wantErr: false}, + {name: "op <", args: []string{"filter", "$prev", "$.x", "n", "<", "1"}, wantErr: false}, + {name: "op >=", args: []string{"filter", "$prev", "$.x", "n", ">=", "1"}, wantErr: false}, + {name: "op <=", args: []string{"filter", "$prev", "$.x", "n", "<=", "1"}, wantErr: false}, + {name: "op !=", args: []string{"filter", "$prev", "$.x", "n", "!=", "1"}, wantErr: false}, + {name: "op contains", args: []string{"filter", "$prev", "$.x", "n", "contains", "1"}, wantErr: false}, + {name: "op startsWith", args: []string{"filter", "$prev", "$.x", "n", "startsWith", "1"}, wantErr: false}, + {name: "op matches", args: []string{"filter", "$prev", "$.x", "n", "matches", "^a$"}, wantErr: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateFilterCommand(tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tc.errSubstr != "" && !strings.Contains(err.Error(), tc.errSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.errSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestFilterMatch exercises every operator across the JSON value types the +// SDK responses actually contain (string / number / bool / nil-via-missing). +// It also locks in the ReDoS-defense behaviour added after upstream PR #165 +// review: oversize patterns and uncompilable regexes return false rather +// than crashing or hanging. +func TestFilterMatch(t *testing.T) { + cases := []struct { + name string + v any + op string + value string + want bool + }{ + // numeric comparisons — float, int, int64, string-coerced + {name: "gt float true", v: 5.0, op: ">", value: "3", want: true}, + {name: "gt float false", v: 2.0, op: ">", value: "3", want: false}, + {name: "ge equal true", v: float64(8), op: ">=", value: "8", want: true}, + {name: "lt int true", v: 1, op: "<", value: "5", want: true}, + {name: "le int64 true", v: int64(4), op: "<=", value: "4", want: true}, + {name: "gt string-coerced", v: "12.5", op: ">", value: "10", want: true}, + {name: "gt bool true is 1", v: true, op: ">", value: "0", want: true}, + // numeric comparisons that should fail gracefully + {name: "gt non-numeric returns false", v: "abc", op: ">", value: "1", want: false}, + {name: "gt nil-ish returns false", v: nil, op: ">", value: "1", want: false}, + // equality — Sprint-based, so works on every type + {name: "eq string", v: "RUNNING", op: "==", value: "RUNNING", want: true}, + {name: "eq number", v: 42, op: "==", value: "42", want: true}, + {name: "eq bool", v: true, op: "==", value: "true", want: true}, + {name: "ne string", v: "RUNNING", op: "!=", value: "STOPPED", want: true}, + {name: "ne same returns false", v: "RUNNING", op: "!=", value: "RUNNING", want: false}, + // contains / startsWith — string-only + {name: "contains hit", v: "metatech-nodehelix", op: "contains", value: "node", want: true}, + {name: "contains miss", v: "metatech-nodehelix", op: "contains", value: "absent", want: false}, + {name: "contains non-string returns false", v: 42, op: "contains", value: "4", want: false}, + {name: "startsWith hit", v: "prod-app", op: "startsWith", value: "prod-", want: true}, + {name: "startsWith miss", v: "prod-app", op: "startsWith", value: "stg-", want: false}, + {name: "startsWith non-string returns false", v: 100, op: "startsWith", value: "1", want: false}, + // matches — the ReDoS-hardened operator + {name: "matches anchored regex", v: "prod-foo", op: "matches", value: `^prod-`, want: true}, + {name: "matches regex no hit", v: "stg-foo", op: "matches", value: `^prod-`, want: false}, + {name: "matches non-string returns false", v: 123, op: "matches", value: `\d+`, want: false}, + {name: "matches malformed regex returns false", v: "abc", op: "matches", value: `^bad[`, want: false}, + { + name: "matches oversize pattern (>256 chars) returns false", + v: "abc", + op: "matches", + value: strings.Repeat("a", maxFilterPatternLen+1), + want: false, + }, + { + name: "matches edge pattern (=256 chars) accepted", + v: "a", + op: "matches", + value: strings.Repeat("a", maxFilterPatternLen), + want: false, // pattern is 256 a's; "a" won't match (needs all 256) + }, + { + name: "matches PCRE-ReDoS pattern handled by RE2 (no hang, returns false)", + v: "aaaaaaaaaaaab", + op: "matches", + value: `(a+)+$`, + want: false, + }, + // unknown op falls through to false (defensive — validateFilterCommand + // should reject these before reaching filterMatch, but this guards + // against any future code path that skips validation) + {name: "unknown op returns false", v: "x", op: "wat", value: "y", want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := filterMatch(tc.v, tc.op, tc.value) + if got != tc.want { + t.Errorf("filterMatch(%v, %q, %q) = %v, want %v", tc.v, tc.op, tc.value, got, tc.want) + } + }) + } +} diff --git a/internal/maker/tencent_prompts.go b/internal/maker/tencent_prompts.go new file mode 100644 index 0000000..95074fd --- /dev/null +++ b/internal/maker/tencent_prompts.go @@ -0,0 +1,323 @@ +package maker + +import "fmt" + +// TencentPlanPrompt returns the Tencent planner prompt without destroyer mode. +func TencentPlanPrompt(question string) string { + return TencentPlanPromptWithMode(question, false) +} + +// TencentPlanPromptWithMode returns the Tencent Cloud maker plan prompt. Like +// Verda's variant, Tencent plans use a custom verb (`tencent-api`) that the +// executor maps directly to SDK action calls via a generic Send. This avoids +// shelling out to tccli and gives us strict input validation in Go before any +// API call is made. +func TencentPlanPromptWithMode(question string, destroyer bool) string { + destructiveRule := "- Avoid any destructive operations (Terminate*, Delete*, Reset* actions). If the user request requires deletion, refuse and produce a discovery-only plan instead." + if destroyer { + destructiveRule = "- Destructive operations (Terminate*, Delete*, Reset*) are allowed ONLY if the user explicitly asked for them." + } + + return fmt.Sprintf(`You are an infrastructure maker planner for Tencent Cloud. + +Your job: produce a concrete, minimal Tencent Cloud execution plan to satisfy the user request, expressed as a sequence of Tencent API action calls. + +Constraints: +- Output ONLY valid JSON. +- Use this schema exactly: +{ + "version": 1, + "createdAt": "RFC3339 timestamp", + "provider": "tencent", + "question": "original user question", + "summary": "short summary of what will be created/changed", + "commands": [ + { + "args": ["tencent-api", "", "", "", ""], + "reason": "why this command is needed", + "produces": { + "OPTIONAL_BINDING_NAME": "$.Response.VpcId" + } + } + ], + "notes": ["optional notes"] +} + +Command verbs (two available): +- "tencent-api" — calls a Tencent Cloud API action. 5 args: [tencent-api, service, Action, region, JSON params]. +- "filter" — client-side post-processor over a PRIOR command's output. 6 args: [filter, sourceIdx, arrayPath, field, op, value]. Does not hit Tencent. + +tencent-api: +- Services available: cvm, vpc, cbs, clb, cdb, postgres, redis, mongodb, tke, tag, cam, monitor, cls, lighthouse, billing. +- Action is the Tencent Cloud API action name in PascalCase exactly as documented (RunInstances, CreateVpc, CreateSecurityGroupPolicies, etc). +- Region is a Tencent region code like ap-singapore, ap-jakarta, ap-tokyo. NEVER use AWS-style region codes. +- JSON params follow the Tencent API request shape verbatim (PascalCase keys, no extra wrapping). +- Use "" for actions that take no parameters. +- Do NOT include shell operators, pipes, redirects, or subshells in any arg. + +filter (use this to ANSWER "find X by criteria" questions — emits the matching subset, not just raw inventory): +- sourceIdx: either "$prev" (the immediately preceding command) or a 1-based numeric string ("1", "2", ...) referencing an earlier command. +- arrayPath: JSONPath into the source body that resolves to an ARRAY (e.g. "$.Response.InstanceSet"). A trailing [*] is tolerated and stripped. +- field: a JSONPath inside each array element (e.g. "Memory", "InstanceState", "CPU", "Placement.Zone"). +- op: one of > < >= <= == != contains startsWith matches +- value: the comparison value as a string. Numeric ops auto-convert. "matches" treats value as a regex. +- Output JSON shape: { matched, total_in, field, op, value, items: [...] }. Only the matched items are returned. +- The filter verb is the right tool for spec-based queries (memory, vCPU count, state, type, public IP presence, etc) because Tencent's Describe* API does NOT support numeric/inequality filters server-side. + +%s + +Rules for commands: +- The "commands" array MUST contain at least 1 command. +- If the user request is ambiguous or missing required details, output a DISCOVERY-ONLY plan with READ-ONLY actions (Describe*). +- Prefer idempotent or reversible operations where possible. + +Planner checklist — run through these in order BEFORE you write any commands: + 1. Is the answer already a single field on a Describe* response? (e.g. CPU count, InstanceState, Zone, public IP, security_group_ids, BundleId, CreatedTime, ExpiredTime). If yes → ONE Describe* call, filter client-side. STOP. + 2. Can the filter be expressed with a typed "Filters" parameter on the Describe* call itself? (e.g. cvm.DescribeInstances supports Filters: [{Name:"instance-state",Values:["STOPPED"]}], also Filter names "zone", "vpc-id", "security-group-id"; vpc.DescribeSecurityGroupPolicies takes SecurityGroupId; clb.DescribeLoadBalancers supports Filters by network type, vpc-id, region). If yes → ONE Describe* with Filters, no chain. + 3. Does the user want to ACT on a list of items (status/terminate/modify) where the downstream action accepts a flat InstanceIds array? → 2-command chain with [*] array binding. Use form. STOP. + 4. Does the user want RUNTIME metrics (CPUUsage %%, MemUsage %%, traffic, packets) across MANY instances? → Cloud Monitor needs one structured Dimensions entry per InstanceId. There is NO fan-out mechanism. Emit a DISCOVERY-ONLY plan (just cvm.DescribeInstances) and add a note: "Live per-instance CPU is available in the dashboard's Monitoring view; this plan only lists which instances exist." Do NOT try to chain. + 5. Is the request a WRITE/CREATE? → produce the create-chain, with placeholders flowing produced IDs forward (VPC_ID → SUBNET_ID → SG_ID → CVM_ID). + 6. Is the request DESTRUCTIVE (Terminate/Delete/Reset/Release)? → only emit if destroyer mode is on; otherwise refuse and emit a discovery alternative. + +Chain shapes you may use: +- A. Single Describe with client filter (use this when step 1 of the checklist matches). +- B. Single Describe with server-side Filters parameter (step 2). +- C. Describe → typed action on the [*] array (step 3) — e.g. DescribeInstances → DescribeInstancesStatus, DescribeInstances → TerminateInstances, DescribeSecurityGroups → DescribeSecurityGroupPolicies (one SG at a time via scalar; for N SGs use [*]+InstanceIds where supported). +- D. Create chain: each create command produces an ID for the next command's placeholder. Use the scalar form <VPC_ID> (i.e. literal angle brackets, uppercase name), NOT array form, because there's exactly one resource per command. +- E. Read → Modify: e.g. DescribeInstances → ModifyInstanceAttribute scalar-bound to a single ID picked by the LLM client-side. +- F. Billing drill-down: DescribeBillSummaryByProduct (one call, returns top categories) → optionally a second DescribeBillResourceSummary for a specific BusinessCode the user is interested in. NO array fanout — billing summaries are already aggregated. +- G. CLS flow: cls.DescribeTopics (list topics) → cls.SearchLog with one TopicId at a time (scalar). For multi-topic, emit discovery-only. +- H. Refuse-with-discovery: when checklist step 4 hits, output ONE Describe* and note the dashboard answer. + +Forbidden shapes (NEVER output these — they cannot work): +- Chained Cloud Monitor calls with array inside Instances[].Dimensions[].Value — won't fan out, error guaranteed. +- "Loop over the previous result" notes with a single command that uses a literal placeholder string like "PLACEHOLDER_INSTANCE_ID" or "EACH_ID". +- More than ONE command that "iterates" or "for each" the previous output — there is no loop construct. Either batch into one call or refuse with discovery. + +Placeholders and bindings: +- You MAY use placeholder tokens like "" or "" in later commands. +- Placeholder names MUST match /^[A-Z0-9_]+$/ — only uppercase, digits, and underscores. "", "{{vpc_id}}", and "${VPC_ID}" are INVALID and will NOT be substituted. +- NEVER emit literal placeholder-like strings such as "PLACEHOLDER_X", "TODO_FILL_IN", or "". If you don't have the value yet, declare it via produces on an earlier command. +- If you use ANY placeholder, ensure an earlier command in the plan includes "produces" mapping the field via JSONPath. +- Tencent responses are always wrapped: {"Response": {...}}. So a created VPC's ID is at "$.Response.Vpc.VpcId", an SG ID is at "$.Response.SecurityGroup.SecurityGroupId", a list of created instances is at "$.Response.InstanceIdSet[0]". +- Placeholders bind to either: + (a) a SCALAR string (e.g. → "vpc-abc12345"), or + (b) a JSON ARRAY LITERAL when the JSONPath uses [*] (e.g. → ["ins-1","ins-2"]). +- For (b), drop the placeholder where a JSON array goes — NOT inside quotes. Correct: "InstanceIds":. Wrong: "InstanceIds":[""], wrong: "InstanceId":"". +- Array placeholders do NOT auto-expand structurally. They only fit positions that accept a flat JSON array of IDs (e.g. cvm.DescribeInstancesStatus, cvm.TerminateInstances). They do NOT fit Cloud Monitor's Instances:[{Dimensions:[{Name,Value}]}] shape — that needs one structured entry per instance and there is no template mechanism for that. +- For queries that would need per-instance Cloud Monitor calls across N instances (e.g. "show CPU for every CVM in region X"), emit a DISCOVERY-ONLY plan with cvm.DescribeInstances and add a note telling the user the dashboard's Monitoring view already aggregates this live. Do NOT try to chain it. + +Static specs vs runtime metrics (CRITICAL — most chained plans fail because of this confusion): +- Tencent's Describe* responses are RICH. A single cvm.DescribeInstances call already returns, for every instance: InstanceId, InstanceName, InstanceState (RUNNING / STOPPED / …), InstanceType, CPU (vCPU count), Memory (GB), CpuTopology, ImageId, OsName, PrivateIpAddresses, PublicIpAddresses, Placement.Zone, VpcId, SubnetId, SecurityGroupIds, CreatedTime, ExpiredTime, InstanceChargeType, RenewFlag, DataDisks, SystemDisk, Tags, and more. Same pattern for vpc.DescribeVpcs, vpc.DescribeSecurityGroups, cbs.DescribeDisks, billing.DescribeBill*, etc. +- Therefore: questions about static SPECS or STATE can be answered with ONE discovery call — the filter is then applied client-side. Examples: + - "CVMs with more than 2 vCPUs" → cvm.DescribeInstances; client filters on CPU > 2. + - "Stopped CVMs in ap-jakarta" → cvm.DescribeInstances; client filters on InstanceState == "STOPPED" (or pass Filters: [{Name: "instance-state", Values: ["STOPPED"]}] in the request). + - "Public-facing CLBs" → clb.DescribeLoadBalancers; client filters on LoadBalancerType == "OPEN". +- Only call monitor.GetMonitorData when the question is about RUNTIME VALUES: utilization (CPUUsage %%, MemUsage %%), traffic (Lan/WanInTraffic/OutTraffic), packets, custom dashboards. Static fields like "CPU count" or "InstanceState" are NEVER metrics — they live on the inventory response. +- Never produce a 2-step plan that first calls DescribeInstances and then GetMonitorData "to check CPU" when the user asked about cores or count or specs — that's the same field, already in the first response. + +Example — list CVMs with more than 2 vCPUs in a region (single Describe + filter verb): +[ + { + "args": ["tencent-api", "cvm", "DescribeInstances", "ap-jakarta", ""], + "reason": "Inventory all CVMs in the region; vCPU count is on each instance" + }, + { + "args": ["filter", "$prev", "$.Response.InstanceSet", "CPU", ">", "2"], + "reason": "Keep only the instances with more than 2 vCPUs and return that subset" + } +] + +Example — find stopped CVMs whose name starts with "prod-": +[ + { + "args": ["tencent-api", "cvm", "DescribeInstances", "ap-jakarta", "{\"Filters\":[{\"Name\":\"instance-state\",\"Values\":[\"STOPPED\"]}]}"], + "reason": "Use server-side Filters for instance-state; client-side for the name prefix" + }, + { + "args": ["filter", "$prev", "$.Response.InstanceSet", "InstanceName", "startsWith", "prod-"], + "reason": "Narrow to the prod-* prefix" + } +] + +When to use filter vs the Resources view: +- Single one-off question, want the answer right now → filter verb (this plan). +- Repeated visual exploration / sortable browsing → the dashboard's Resources view does this without Maker (Type=cvm, Region=ap-jakarta). Mention it in the notes if the user might prefer it. + +Important Tencent surface knowledge: +- All resource creation requires an explicit Region argument. Discovery is also per-region. +- VPC creation (vpc.CreateVpc) needs CidrBlock and VpcName. +- Subnet creation (vpc.CreateSubnet) needs VpcId, SubnetName, CidrBlock, and Zone (e.g. ap-singapore-1). +- Security Group creation (vpc.CreateSecurityGroup) is two steps: CreateSecurityGroup then CreateSecurityGroupPolicies to add rules. +- CVM creation (cvm.RunInstances) needs ImageId, InstanceType, Placement.Zone, plus VirtualPrivateCloud.VpcId+SubnetId for non-default networks. Always set InstanceCount=1 unless the user explicitly asked for more. +- For ssh access, also set LoginSettings.KeyIds (if you have one) or LoginSettings.Password (must be 8-30 chars, complex). +- Always set InstanceChargeType to "POSTPAID_BY_HOUR" unless user asks for prepaid. + +Common operations: + +Create a small VPC with one subnet and a permissive SG: +[ + { + "args": ["tencent-api", "vpc", "CreateVpc", "ap-singapore", "{\"VpcName\":\"clanker-demo\",\"CidrBlock\":\"10.99.0.0/16\"}"], + "reason": "Create a new VPC for the demo workload", + "produces": {"VPC_ID": "$.Response.Vpc.VpcId"} + }, + { + "args": ["tencent-api", "vpc", "CreateSubnet", "ap-singapore", "{\"VpcId\":\"\",\"SubnetName\":\"clanker-demo-subnet\",\"CidrBlock\":\"10.99.1.0/24\",\"Zone\":\"ap-singapore-1\"}"], + "reason": "Add a subnet inside the new VPC", + "produces": {"SUBNET_ID": "$.Response.Subnet.SubnetId"} + }, + { + "args": ["tencent-api", "vpc", "CreateSecurityGroup", "ap-singapore", "{\"GroupName\":\"clanker-demo-sg\",\"GroupDescription\":\"clanker demo security group\"}"], + "reason": "Create the SG that will be attached to the CVM", + "produces": {"SG_ID": "$.Response.SecurityGroup.SecurityGroupId"} + }, + { + "args": ["tencent-api", "vpc", "CreateSecurityGroupPolicies", "ap-singapore", "{\"SecurityGroupId\":\"\",\"SecurityGroupPolicySet\":{\"Ingress\":[{\"Protocol\":\"TCP\",\"Port\":\"22\",\"CidrBlock\":\"10.0.0.0/8\",\"Action\":\"ACCEPT\",\"PolicyDescription\":\"private SSH\"}]}}"], + "reason": "Allow SSH from the VPC private range only" + } +] + +Create a small CVM in an existing VPC: +[ + { + "args": ["tencent-api", "cvm", "RunInstances", "ap-singapore", "{\"InstanceChargeType\":\"POSTPAID_BY_HOUR\",\"Placement\":{\"Zone\":\"ap-singapore-1\"},\"InstanceType\":\"S5.SMALL2\",\"ImageId\":\"img-eb30mz89\",\"VirtualPrivateCloud\":{\"VpcId\":\"\",\"SubnetId\":\"\"},\"SecurityGroupIds\":[\"\"],\"InstanceCount\":1,\"InstanceName\":\"clanker-demo-cvm\"}"], + "reason": "Provision a small CVM in the new subnet", + "produces": {"CVM_ID": "$.Response.InstanceIdSet[0]"} + } +] + +Discover available zones (no params): +{ + "args": ["tencent-api", "cvm", "DescribeZones", "ap-singapore", ""], + "reason": "Enumerate availability zones in this region" +} + +Describe existing VPCs in a region: +{ + "args": ["tencent-api", "vpc", "DescribeVpcs", "ap-singapore", ""], + "reason": "List VPCs to see what is already there" +} + +Add a single ingress rule to an existing SG: +{ + "args": ["tencent-api", "vpc", "CreateSecurityGroupPolicies", "ap-singapore", "{\"SecurityGroupId\":\"sg-abc12345\",\"SecurityGroupPolicySet\":{\"Ingress\":[{\"Protocol\":\"TCP\",\"Port\":\"443\",\"CidrBlock\":\"0.0.0.0/0\",\"Action\":\"ACCEPT\",\"PolicyDescription\":\"public HTTPS\"}]}}"], + "reason": "Open HTTPS to the world" +} + +Delete a security group rule by index (DESTRUCTIVE — requires destroyer mode): +{ + "args": ["tencent-api", "vpc", "DeleteSecurityGroupPolicies", "ap-singapore", "{\"SecurityGroupId\":\"sg-abc12345\",\"SecurityGroupPolicySet\":{\"Ingress\":[{\"PolicyIndex\":4}]}}"], + "reason": "Remove the risky 0.0.0.0/0 → 5432 rule (index 4)" +} + +Discover CVMs then check their power state in one chained pair (uses [*] array binding): +[ + { + "args": ["tencent-api", "cvm", "DescribeInstances", "ap-singapore", ""], + "reason": "List all CVMs in the region", + "produces": {"CVM_IDS": "$.Response.InstanceSet[*].InstanceId"} + }, + { + "args": ["tencent-api", "cvm", "DescribeInstancesStatus", "ap-singapore", "{\"InstanceIds\":}"], + "reason": "Get the status of every discovered instance in one call" + } +] +Note the placeholder shape: is placed where a JSON array goes (right after the colon), NOT inside quotes. Clanker substitutes it with a literal array like ["ins-1","ins-2"]. + +Anti-pattern (DO NOT do this — there is no fan-out mechanism): +[ + { "args": ["tencent-api","cvm","DescribeInstances","ap-singapore",""], "produces": {"IDS":"$.Response.InstanceSet[*].InstanceId"} }, + { "args": ["tencent-api","monitor","GetMonitorData","ap-singapore","{\"Namespace\":\"QCE/CVM\",\"MetricName\":\"CPUUsage\",\"Instances\":[{\"Dimensions\":[{\"Name\":\"InstanceId\",\"Value\":\"\"}]}]}"] } +] +Why wrong: Cloud Monitor needs ONE structured entry per InstanceId. An array placeholder can't expand into N Dimensions objects. For "CPU usage for all CVMs" type queries, respond with a discovery-only plan and tell the user the dashboard's Monitoring view already gives this aggregated live. + +Read CPU usage for KNOWN CVMs (Cloud Monitor — single batched call, hardcoded IDs): +{ + "args": ["tencent-api", "monitor", "GetMonitorData", "ap-singapore", "{\"Namespace\":\"QCE/CVM\",\"MetricName\":\"CPUUsage\",\"Period\":60,\"StartTime\":\"2026-05-16T13:00:00Z\",\"EndTime\":\"2026-05-16T14:00:00Z\",\"Instances\":[{\"Dimensions\":[{\"Name\":\"InstanceId\",\"Value\":\"ins-aaaa1111\"}]},{\"Dimensions\":[{\"Name\":\"InstanceId\",\"Value\":\"ins-bbbb2222\"}]}]}"], + "reason": "Pull last hour's CPU usage for the listed CVMs in one call" +} +Rules for monitor.GetMonitorData: +- Action is "GetMonitorData" (NOT "GetProductMetricData" — that does not exist). +- Namespace for CVM is "QCE/CVM" with metric names CPUUsage, MemUsage, LanOuttraffic, LanIntraffic, WanOuttraffic, WanIntraffic; dimension key is "InstanceId" (PascalCase). +- Namespace for Lighthouse is "QCE/LIGHTHOUSE" with metric names CpuUsage, MemUsage, DiskUsage, LighthouseInpkg, LighthouseOutpkg, LighthouseIntraffic, LighthouseOuttraffic; dimension key is "instanceid" (lowercase). +- StartTime / EndTime are RFC3339 UTC strings (e.g. "2026-05-16T13:00:00Z"). NEVER use negative integers or relative offsets. +- Dimensions is an array of {Name, Value} objects, NOT a {dimensions: {key: value}} map. +- To fetch metrics for multiple instances, put ALL of them in the same Instances[] of one call. Do NOT chain N commands, one per instance — there is no loop construct. +- If the user wants to filter the result ("CPU > 15%%"), the plan still emits a single GetMonitorData call with all candidate InstanceIds; the caller filters DataPoints client-side. + +Filter CVMs server-side by state (single call, no client-side filter needed): +{ + "args": ["tencent-api", "cvm", "DescribeInstances", "ap-singapore", "{\"Filters\":[{\"Name\":\"instance-state\",\"Values\":[\"STOPPED\"]}]}"], + "reason": "List only stopped CVMs in the region using a native Filter" +} + +Audit an SG: read rules, then patch a risky one (Read → Modify scalar binding): +[ + { + "args": ["tencent-api", "vpc", "DescribeSecurityGroupPolicies", "ap-singapore", "{\"SecurityGroupId\":\"sg-abc12345\"}"], + "reason": "Inspect the current ingress and egress policies for the SG", + "produces": {"SG_ID": "$.Response.SecurityGroupPolicySet.SecurityGroupId"} + }, + { + "args": ["tencent-api", "vpc", "ReplaceSecurityGroupPolicy", "ap-singapore", "{\"SecurityGroupId\":\"\",\"SecurityGroupPolicySet\":{\"Version\":\"0\",\"Ingress\":[{\"PolicyIndex\":0,\"Protocol\":\"TCP\",\"Port\":\"443\",\"CidrBlock\":\"10.0.0.0/8\",\"Action\":\"ACCEPT\",\"PolicyDescription\":\"tightened from 0.0.0.0/0 to private range\"}]}}"], + "reason": "Replace the risky public-ingress rule with a private-CIDR rule" + } +] + +Billing drill-down (one summary, optional follow-up per product code): +[ + { + "args": ["tencent-api", "billing", "DescribeBillSummaryByProduct", "ap-singapore", "{\"Month\":\"2026-04\"}"], + "reason": "Get April's spend grouped by Tencent product; user picks the BusinessCode to drill into" + } +] + +CLS log inspection (list topics, then search ONE of them — multi-topic search has no fan-out): +[ + { + "args": ["tencent-api", "cls", "DescribeTopics", "ap-singapore", "{\"Limit\":50}"], + "reason": "Enumerate CLS topics in the region", + "produces": {"TOPIC_ID": "$.Response.Topics[0].TopicId"} + }, + { + "args": ["tencent-api", "cls", "SearchLog", "ap-singapore", "{\"TopicId\":\"\",\"From\":1715800000000,\"To\":1715803600000,\"Query\":\"*\",\"Limit\":100}"], + "reason": "Search the first matching topic in a 1-hour window" + } +] + +Read billing summary by product (no chaining needed — one call): +{ + "args": ["tencent-api", "billing", "DescribeBillSummaryByProduct", "ap-singapore", "{\"Month\":\"2026-04\"}"], + "reason": "Pull last month's cost breakdown by Tencent product" +} +Rules for billing: +- Tencent's billing service short-name is "billing". Valid read actions: DescribeBillSummaryByProduct, DescribeBillSummaryByPayMode, DescribeBillSummaryByRegion, DescribeBillResourceSummary, DescribeBillDetail. +- "Month" is a YYYY-MM string for the closed month. Always pick a CLOSED month — the current month is partial. +- There is NO "DescribeBillSummary" action — you must pick a By* variant. + +Read CLS log topics in a region: +{ + "args": ["tencent-api", "cls", "DescribeTopics", "ap-singapore", "{\"Limit\":100}"], + "reason": "List all CLS topics in this region" +} +Rules for cls: +- Service short-name is "cls" (Cloud Log Service), not "log" or "logs". +- Use SearchLog (NOT QueryLog) to query log content of a single topic. + +Anti-patterns to NEVER produce (these will be rejected): +- monitor.GetProductMetricData — Tencent has no such action. Use GetMonitorData. +- monitor.DescribeMonitorData / DescribeMetricData — same, use GetMonitorData. +- cvm.ListInstances / vpc.ListVpcs — Tencent uses Describe* exclusively, never List*. +- A 2nd command that "iterates" over the results of a 1st command — there is no loop. Batch into a single multi-instance call instead. + +Terminate an instance (DESTRUCTIVE): +{ + "args": ["tencent-api", "cvm", "TerminateInstances", "ap-singapore", "{\"InstanceIds\":[\"ins-abc12345\"]}"], + "reason": "Tear down the demo CVM" +} + +User request: %s + +Output only the JSON plan. Do NOT wrap in markdown code fences.`, destructiveRule, question) +} diff --git a/internal/tencent/antiddos.go b/internal/tencent/antiddos.go new file mode 100644 index 0000000..00afc6f --- /dev/null +++ b/internal/tencent/antiddos.go @@ -0,0 +1,93 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + antiddos "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos/v20200309" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listAntiDDoS prints every Anti-DDoS Advanced BGP-IP instance. +func listAntiDDoS(c *Client) error { + client, err := newAntiDDoSClient(c) + if err != nil { + return fmt.Errorf("init antiddos client: %w", err) + } + req := antiddos.NewDescribeListBGPIPInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeListBGPIPInstances(req) + if err != nil { + return fmt.Errorf("DescribeListBGPIPInstances: %w", friendlyError(err)) + } + + fmt.Println("Tencent Anti-DDoS Advanced (BGP-IP) Instances:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.InstanceList) == 0 { + fmt.Println(" No Anti-DDoS Advanced instances (account uses Basic protection only)") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATUS\tREGION\tCREATED\tEXPIRES") + for _, i := range resp.Response.InstanceList { + region := "-" + if i.Region != nil { + region = derefString(i.Region.Region) + } + instanceID := "-" + if i.InstanceDetail != nil { + instanceID = derefString(i.InstanceDetail.InstanceId) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + instanceID, + derefString(i.Name), + derefString(i.Status), + region, + derefString(i.CreatedTime), + derefString(i.ExpiredTime), + ) + } + return tw.Flush() +} + +func newAntiDDoSClient(c *Client) (*antiddos.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("antiddos.tencentcloudapi.com") + return antiddos.NewClient(cred, "ap-guangzhou", cpf) +} + +// hasAntiDDoSAdvanced reports whether the account has *any* Anti-DDoS +// Advanced subscription. The detailed per-IP coverage check is gated +// behind that flag — if the account has 0 Advanced instances, then by +// definition every public IP is on Basic protection only. +func hasAntiDDoSAdvanced(c *Client) (bool, []string, error) { + client, err := newAntiDDoSClient(c) + if err != nil { + return false, nil, err + } + req := antiddos.NewDescribeListBGPIPInstancesRequest() + var offset, limit uint64 = 0, 200 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeListBGPIPInstances(req) + if err != nil { + return false, nil, friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.InstanceList) == 0 { + return false, nil, nil + } + var ids []string + for _, i := range resp.Response.InstanceList { + if i.InstanceDetail != nil { + if id := strings.TrimSpace(derefString(i.InstanceDetail.InstanceId)); id != "" { + ids = append(ids, id) + } + } + } + return true, ids, nil +} diff --git a/internal/tencent/audit_coverage.go b/internal/tencent/audit_coverage.go new file mode 100644 index 0000000..0ebecd3 --- /dev/null +++ b/internal/tencent/audit_coverage.go @@ -0,0 +1,73 @@ +package tencent + +import ( + "context" + "encoding/json" + + cloudaudit "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit/v20190319" +) + +// AuditLogCoverageScanJSON returns the high-signal answer to "is the +// account logging API calls at all?" Cloud Audit is account-global so this +// audit takes no region parameter. Posture: +// - NO_TRACKS : no audit tracks configured (no API-call audit trail) +// - ALL_DISABLED: tracks exist but all are disabled +// - PARTIAL : some enabled, some not +// - FULL : every track is enabled +func (c *Client) AuditLogCoverageScanJSON(ctx context.Context) (string, error) { + client, err := newCloudAuditClient(c) + if err != nil { + return "", err + } + resp, err := client.ListAudits(cloudaudit.NewListAuditsRequest()) + if err != nil { + return "", friendlyError(err) + } + + type trackRow struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + COSBucket string `json:"cos_bucket,omitempty"` + Prefix string `json:"log_prefix,omitempty"` + } + var tracks []trackRow + enabledCount, disabledCount := 0, 0 + if resp != nil && resp.Response != nil { + for _, a := range resp.Response.AuditSummarys { + en := derefInt64Raw(a.AuditStatus) == 1 + if en { + enabledCount++ + } else { + disabledCount++ + } + tracks = append(tracks, trackRow{ + Name: derefStringRaw(a.AuditName), + Enabled: en, + COSBucket: derefStringRaw(a.CosBucketName), + Prefix: derefStringRaw(a.LogFilePrefix), + }) + } + } + posture := "NO_TRACKS" + if len(tracks) > 0 { + switch { + case enabledCount > 0 && disabledCount == 0: + posture = "FULL" + case enabledCount > 0 && disabledCount > 0: + posture = "PARTIAL" + default: + posture = "ALL_DISABLED" + } + } + out := struct { + Posture string `json:"posture"` + EnabledCount int `json:"enabled_count"` + DisabledCount int `json:"disabled_count"` + Tracks []trackRow `json:"tracks"` + }{posture, enabledCount, disabledCount, tracks} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/tencent/billing.go b/internal/tencent/billing.go new file mode 100644 index 0000000..95829cf --- /dev/null +++ b/internal/tencent/billing.go @@ -0,0 +1,479 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + + billing "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing/v20180709" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listBillByProduct prints a per-service cost breakdown for a given month. +// Tencent's billing API treats month as both BeginTime and EndTime so we +// always pass the same value. +func listBillByProduct(c *Client, month string) error { + if month == "" { + month = time.Now().Format("2006-01") + } + client, err := newBillingClient(c) + if err != nil { + return fmt.Errorf("init billing client: %w", err) + } + req := billing.NewDescribeBillSummaryByProductRequest() + req.BeginTime = &month + req.EndTime = &month + resp, err := client.DescribeBillSummaryByProduct(req) + if err != nil { + return fmt.Errorf("DescribeBillSummaryByProduct: %w", friendlyError(err)) + } + + fmt.Printf("Tencent Cloud Cost by Product — %s:\n\n", month) + if resp == nil || resp.Response == nil || resp.Response.SummaryOverview == nil || len(resp.Response.SummaryOverview) == 0 { + fmt.Println(" No billing data for this month") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "PRODUCT\tREAL_COST\tCASH\tINCENTIVE\tVOUCHER\tPCT") + var total float64 + for _, it := range resp.Response.SummaryOverview { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s%%\n", + derefString(it.BusinessCodeName), + derefString(it.RealTotalCost), + derefString(it.CashPayAmount), + derefString(it.IncentivePayAmount), + derefString(it.VoucherPayAmount), + derefString(it.RealTotalCostRatio), + ) + if it.RealTotalCost != nil { + if v, err := strconv.ParseFloat(*it.RealTotalCost, 64); err == nil { + total += v + } + } + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Printf("\nTotal: %.4f\n", total) + return nil +} + +// listBillResourceTop prints the most expensive resources for the month. +func listBillResourceTop(c *Client, month string, top int) error { + if month == "" { + month = time.Now().Format("2006-01") + } + if top <= 0 { + top = 20 + } + client, err := newBillingClient(c) + if err != nil { + return fmt.Errorf("init billing client: %w", err) + } + req := billing.NewDescribeBillResourceSummaryRequest() + var offset uint64 = 0 + limit := uint64(top) + if limit > 1000 { + limit = 1000 + } + req.Offset = &offset + req.Limit = &limit + req.Month = &month + period := "byUsedTime" + req.PeriodType = &period + resp, err := client.DescribeBillResourceSummary(req) + if err != nil { + return fmt.Errorf("DescribeBillResourceSummary: %w", friendlyError(err)) + } + + fmt.Printf("Top %d Resources by Cost — %s:\n\n", top, month) + if resp == nil || resp.Response == nil || len(resp.Response.ResourceSummarySet) == 0 { + fmt.Println(" No resource-level billing data for this month") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "PRODUCT\tRESOURCE_ID\tNAME\tREGION\tPAY_MODE\tACTION\tCOST") + for _, r := range resp.Response.ResourceSummarySet { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + derefString(r.BusinessCodeName), + derefString(r.ResourceId), + derefString(r.ResourceName), + derefString(r.RegionName), + derefString(r.PayModeName), + derefString(r.ActionTypeName), + derefString(r.RealTotalCost), + ) + } + return tw.Flush() +} + +func newBillingClient(c *Client) (*billing.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("billing.tencentcloudapi.com") + return billing.NewClient(cred, "ap-guangzhou", cpf) // billing is global +} + +// billFeeBreakdown is the fee-type decomposition of a month's bill. It comes +// from DescribeCostExplorerSummary — the only Tencent billing API that +// separates out tax. DescribeBillSummaryByProduct (used for the per-product +// list) returns RealCost but no tax field, which is why a Clanker cost +// total never matched the console's tax-inclusive headline. +// +// Reconciliation (verified against a real April bill): +// +// consumption = voucher + cash_before_tax + tax +// cash_incl_tax = cash_before_tax + tax ← the console's headline "Total Cost" +type billFeeBreakdown struct { + Consumption float64 `json:"consumption"` // total RealCost: voucher + cash + tax + Voucher float64 `json:"voucher"` // amount covered by vouchers + CashBeforeTax float64 `json:"cash_before_tax"` // cash portion, pre-tax + Tax float64 `json:"tax"` // tax amount + CashInclTax float64 `json:"cash_incl_tax"` // cash_before_tax + tax (out-of-pocket) + Note string `json:"note,omitempty"` // set when the breakdown call failed +} + +// monthDateRange turns "2026-04" into the [begin, end] datetime strings +// DescribeCostExplorerSummary expects (yyyy-mm-dd hh:ii:ss). Begin is the +// first day at 00:00:00, end is the last day at 23:59:59. +func monthDateRange(month string) (string, string) { + t, err := time.Parse("2006-01", strings.TrimSpace(month)) + if err != nil { + now := time.Now() + t = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + } + begin := t.Format("2006-01-02") + " 00:00:00" + end := t.AddDate(0, 1, -1).Format("2006-01-02") + " 23:59:59" + return begin, end +} + +// billFeeSummary pulls the voucher / cash-before-tax / tax decomposition for +// a month via DescribeCostExplorerSummary (Dimensions=feeType, FeeType=cost). +// +// The Detail item names are localized display strings — for an English +// account they are "Voucher", "Tax Amount", "Total Amount After Discount +// (Excluding Tax)". We match defensively on lowercased substrings so a +// locale change doesn't silently break the mapping. +func billFeeSummary(client *billing.Client, month string) (billFeeBreakdown, error) { + var out billFeeBreakdown + begin, end := monthDateRange(month) + + req := billing.NewDescribeCostExplorerSummaryRequest() + billType, periodType, dim, feeType := "1", "month", "feeType", "cost" + req.BeginTime, req.EndTime = &begin, &end + req.BillType, req.PeriodType = &billType, &periodType + req.Dimensions, req.FeeType = &dim, &feeType + var pageSize, pageNo uint64 = 100, 1 + req.PageSize, req.PageNo = &pageSize, &pageNo + + resp, err := client.DescribeCostExplorerSummary(req) + if err != nil { + return out, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return out, nil + } + if td := resp.Response.TotalDetail; td != nil { + out.Consumption = parseFloat(derefString(td.Total)) + } + for _, d := range resp.Response.Detail { + if d == nil { + continue + } + name := strings.ToLower(derefStringRaw(d.Name)) + val := parseFloat(derefString(d.Total)) + // Order matters: the cash line is literally "Total Amount After + // Discount (Excluding Tax)" — it contains the word "tax", so the + // discount check MUST come before the tax check or the cash line + // gets misclassified as tax. + switch { + case strings.Contains(name, "discount"): + out.CashBeforeTax = val + case strings.Contains(name, "voucher"): + out.Voucher = val + case strings.Contains(name, "tax"): + out.Tax = val + } + } + out.CashInclTax = out.CashBeforeTax + out.Tax + return out, nil +} + +// BillByProductJSON returns the per-service cost breakdown as JSON for the +// dashboard's Cost Explorer view. The `summary` object adds the tax-aware +// waterfall (consumption → voucher / cash / tax) so the dashboard total can +// match the Tencent console's tax-inclusive headline. +func (c *Client) BillByProductJSON(ctx context.Context, month string) (string, error) { + if month == "" { + month = time.Now().Format("2006-01") + } + client, err := newBillingClient(c) + if err != nil { + return "", err + } + req := billing.NewDescribeBillSummaryByProductRequest() + req.BeginTime = &month + req.EndTime = &month + resp, err := client.DescribeBillSummaryByProduct(req) + if err != nil { + return "", friendlyError(err) + } + type productCost struct { + Code string `json:"code"` + Name string `json:"name"` + RealCost float64 `json:"real_cost"` + CashPay float64 `json:"cash_pay"` + IncentivePay float64 `json:"incentive_pay"` + VoucherPay float64 `json:"voucher_pay"` + Ratio string `json:"ratio,omitempty"` + } + var items []productCost + var total float64 + if resp != nil && resp.Response != nil { + for _, it := range resp.Response.SummaryOverview { + rc := parseFloat(derefString(it.RealTotalCost)) + total += rc + items = append(items, productCost{ + Code: derefStringRaw(it.BusinessCode), + Name: derefStringRaw(it.BusinessCodeName), + RealCost: rc, + CashPay: parseFloat(derefString(it.CashPayAmount)), + IncentivePay: parseFloat(derefString(it.IncentivePayAmount)), + VoucherPay: parseFloat(derefString(it.VoucherPayAmount)), + Ratio: derefStringRaw(it.RealTotalCostRatio), + }) + } + } + + // Pull the tax-aware waterfall. A failure here is non-fatal — the + // per-product list is still useful; summary just stays zero-valued. + summary, ferr := billFeeSummary(client, month) + if ferr != nil { + summary.Note = "fee breakdown unavailable: " + ferr.Error() + } + + out := struct { + Month string `json:"month"` + Total float64 `json:"total"` + Summary billFeeBreakdown `json:"summary"` + Items []productCost `json:"items"` + }{month, total, summary, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// BillResourceTopJSON returns the top-N resources by spend for the month. +func (c *Client) BillResourceTopJSON(ctx context.Context, month string, top int) (string, error) { + if month == "" { + month = time.Now().Format("2006-01") + } + if top <= 0 || top > 200 { + top = 50 + } + client, err := newBillingClient(c) + if err != nil { + return "", err + } + req := billing.NewDescribeBillResourceSummaryRequest() + var offset uint64 = 0 + limit := uint64(top) + req.Offset = &offset + req.Limit = &limit + req.Month = &month + period := "byUsedTime" + req.PeriodType = &period + resp, err := client.DescribeBillResourceSummary(req) + if err != nil { + return "", friendlyError(err) + } + type resourceCost struct { + Product string `json:"product"` + ResourceID string `json:"resource_id"` + Name string `json:"name,omitempty"` + Region string `json:"region,omitempty"` + PayMode string `json:"pay_mode,omitempty"` + Action string `json:"action,omitempty"` + Cost float64 `json:"cost"` + } + var items []resourceCost + if resp != nil && resp.Response != nil { + for _, r := range resp.Response.ResourceSummarySet { + items = append(items, resourceCost{ + Product: derefStringRaw(r.BusinessCodeName), + ResourceID: derefStringRaw(r.ResourceId), + Name: derefStringRaw(r.ResourceName), + Region: derefStringRaw(r.RegionName), + PayMode: derefStringRaw(r.PayModeName), + Action: derefStringRaw(r.ActionTypeName), + Cost: parseFloat(derefString(r.RealTotalCost)), + }) + } + } + out := struct { + Month string `json:"month"` + Top int `json:"top"` + Items []resourceCost `json:"items"` + }{month, top, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// VoucherByOwnerJSON attributes a month's voucher deduction to the account +// that owns each billed resource. +// +// This is the only month-scoped, per-account view of voucher spend. The +// voucher APIs (DescribeVoucherInfo / DescribeVoucherUsageDetails) carry an +// OwnerUin only on the voucher itself and have no month dimension, but +// DescribeBillResourceSummary tags every billed line with OwnerUin / +// PayerUin / OperateUin alongside VoucherPayAmount — so summing +// VoucherPayAmount grouped by OwnerUin answers "which account had voucher +// deductions, and how much, this month". +// +// It pages the full month (DescribeBillResourceSummary, byUsedTime, 1000 +// lines/page) rather than a top-N slice. +func (c *Client) VoucherByOwnerJSON(ctx context.Context, month string) (string, error) { + if month == "" { + month = time.Now().Format("2006-01") + } + client, err := newBillingClient(c) + if err != nil { + return "", err + } + + type ownerAgg struct { + voucherPay float64 + realCost float64 + cashPay float64 + resourceCount int + payers map[string]bool + operators map[string]bool + } + owners := map[string]*ownerAgg{} + var totalVoucher, totalReal float64 + var lineItems int + + var offset uint64 = 0 + const pageSize uint64 = 1000 + period := "byUsedTime" + for { + req := billing.NewDescribeBillResourceSummaryRequest() + o, l := offset, pageSize + req.Offset = &o + req.Limit = &l + req.Month = &month + req.PeriodType = &period + resp, err := client.DescribeBillResourceSummary(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + set := resp.Response.ResourceSummarySet + for _, r := range set { + if r == nil { + continue + } + ow := derefStringRaw(r.OwnerUin) + a := owners[ow] + if a == nil { + a = &ownerAgg{payers: map[string]bool{}, operators: map[string]bool{}} + owners[ow] = a + } + vp := parseFloat(derefString(r.VoucherPayAmount)) + rc := parseFloat(derefString(r.RealTotalCost)) + a.voucherPay += vp + a.realCost += rc + a.cashPay += parseFloat(derefString(r.CashPayAmount)) + a.resourceCount++ + if p := derefStringRaw(r.PayerUin); p != "" { + a.payers[p] = true + } + if op := derefStringRaw(r.OperateUin); op != "" { + a.operators[op] = true + } + totalVoucher += vp + totalReal += rc + lineItems++ + } + if uint64(len(set)) < pageSize { + break + } + offset += pageSize + } + + type ownerVoucher struct { + OwnerUin string `json:"owner_uin"` + VoucherPay float64 `json:"voucher_pay"` + RealCost float64 `json:"real_cost"` + CashPay float64 `json:"cash_pay"` + ResourceCount int `json:"resource_count"` + PayerUins []string `json:"payer_uins,omitempty"` + OperateUins []string `json:"operate_uins,omitempty"` + } + items := make([]ownerVoucher, 0, len(owners)) + for uin, a := range owners { + items = append(items, ownerVoucher{ + OwnerUin: uin, + VoucherPay: a.voucherPay, + RealCost: a.realCost, + CashPay: a.cashPay, + ResourceCount: a.resourceCount, + PayerUins: sortedKeys(a.payers), + OperateUins: sortedKeys(a.operators), + }) + } + sort.Slice(items, func(i, j int) bool { + if items[i].VoucherPay != items[j].VoucherPay { + return items[i].VoucherPay > items[j].VoucherPay + } + return items[i].OwnerUin < items[j].OwnerUin + }) + + out := struct { + Month string `json:"month"` + TotalVoucher float64 `json:"total_voucher"` + TotalReal float64 `json:"total_real_cost"` + OwnerCount int `json:"owner_count"` + LineItems int `json:"line_items"` + Owners []ownerVoucher `json:"owners"` + }{month, totalVoucher, totalReal, len(items), lineItems, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// sortedKeys returns a set's keys as a sorted slice. +func sortedKeys(m map[string]bool) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func parseFloat(s string) float64 { + if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { + return v + } + return 0 +} diff --git a/internal/tencent/cam.go b/internal/tencent/cam.go new file mode 100644 index 0000000..1627090 --- /dev/null +++ b/internal/tencent/cam.go @@ -0,0 +1,54 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + cam "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam/v20190116" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listCAMUsers prints every CAM sub-account user. CAM is account-global so +// region is irrelevant. The SDK version we ship does not expose MFA flags +// from ListUsers/GetUser, so the hygiene audit operates on what is visible +// (presence/absence of console_login, phone, email). +func listCAMUsers(c *Client) error { + client, err := newCAMClient(c) + if err != nil { + return fmt.Errorf("init cam client: %w", err) + } + resp, err := client.ListUsers(cam.NewListUsersRequest()) + if err != nil { + return fmt.Errorf("ListUsers: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud CAM Sub-Account Users:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.Data) == 0 { + fmt.Println(" No sub-account users found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "UID\tNAME\tNICKNAME\tEMAIL\tCONSOLE_LOGIN\tPHONE_SET\tCREATED") + for _, u := range resp.Response.Data { + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%v\t%v\t%s\n", + derefUint64(u.Uid), + derefString(u.Name), + derefString(u.NickName), + derefString(u.Email), + derefUint64(u.ConsoleLogin) == 1, + strings.TrimSpace(derefString(u.PhoneNum)) != "" && derefString(u.PhoneNum) != "-", + derefString(u.CreateTime), + ) + } + return tw.Flush() +} + +func newCAMClient(c *Client) (*cam.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cam.tencentcloudapi.com") + return cam.NewClient(cred, "ap-guangzhou", cpf) +} diff --git a/internal/tencent/cbs.go b/internal/tencent/cbs.go new file mode 100644 index 0000000..b491ada --- /dev/null +++ b/internal/tencent/cbs.go @@ -0,0 +1,100 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + cbs "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs/v20170312" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listCBS prints every Cloud Block Storage (disk) across the given regions. +// The encryption flag is the high-value column for security audits. +func listCBS(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + d *cbs.Disk + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newCBSClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init cbs client: %v", r, err)) + continue + } + req := cbs.NewDescribeDisksRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDisks(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, d := range resp.Response.DiskSet { + rows = append(rows, row{region: r, d: d}) + } + } + + header := fmt.Sprintf("Tencent Cloud Block Storage (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud Block Storage (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No CBS volumes found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tDISK_ID\tNAME\tTYPE\tSIZE_GB\tSTATE\tENCRYPTED\tINSTANCE_ID\tZONE") + } else { + fmt.Fprintln(tw, "DISK_ID\tNAME\tTYPE\tSIZE_GB\tSTATE\tENCRYPTED\tINSTANCE_ID\tZONE") + } + for _, r := range rows { + d := r.d + zone := "-" + if d.Placement != nil { + zone = derefString(d.Placement.Zone) + } + fields := []string{ + derefString(d.DiskId), + derefString(d.DiskName), + derefString(d.DiskType), + fmt.Sprintf("%d", derefUint64(d.DiskSize)), + derefString(d.DiskState), + fmt.Sprintf("%v", derefBool(d.Encrypt)), + derefString(d.InstanceId), + zone, + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newCBSClient(c *Client, region string) (*cbs.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cbs.tencentcloudapi.com") + return cbs.NewClient(cred, region, cpf) +} diff --git a/internal/tencent/cdn_edge.go b/internal/tencent/cdn_edge.go new file mode 100644 index 0000000..9672e75 --- /dev/null +++ b/internal/tencent/cdn_edge.go @@ -0,0 +1,77 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listCDNDomains prints every CDN domain. CDN is account-global. +func listCDNDomains(c *Client) error { + client, err := newCDNClient(c) + if err != nil { + return fmt.Errorf("init cdn client: %w", err) + } + req := cdn.NewDescribeDomainsRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDomains(req) + if err != nil { + return fmt.Errorf("DescribeDomains: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud CDN Domains:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.Domains) == 0 { + fmt.Println(" No CDN domains found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DOMAIN_ID\tDOMAIN\tCNAME\tSTATUS\tSERVICE\tCREATED") + for _, d := range resp.Response.Domains { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + derefString(d.ResourceId), + derefString(d.Domain), + derefString(d.Cname), + derefString(d.Status), + derefString(d.ServiceType), + derefString(d.CreateTime), + ) + } + return tw.Flush() +} + +func newCDNClient(c *Client) (*cdn.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cdn.tencentcloudapi.com") + return cdn.NewClient(cred, "ap-guangzhou", cpf) // CDN ignores region +} + +// listCDNDomainNames returns just the domain names (used by audits). +func listCDNDomainNames(c *Client) []string { + client, err := newCDNClient(c) + if err != nil { + return nil + } + req := cdn.NewDescribeDomainsRequest() + var offset, limit int64 = 0, 200 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDomains(req) + if err != nil || resp == nil || resp.Response == nil { + return nil + } + out := make([]string, 0, len(resp.Response.Domains)) + for _, d := range resp.Response.Domains { + if s := strings.TrimSpace(derefString(d.Domain)); s != "" && s != "-" { + out = append(out, s) + } + } + return out +} diff --git a/internal/tencent/charge_mode.go b/internal/tencent/charge_mode.go new file mode 100644 index 0000000..4739c66 --- /dev/null +++ b/internal/tencent/charge_mode.go @@ -0,0 +1,109 @@ +package tencent + +import "strings" + +// Tencent uses two billing-mode conventions across its services: +// +// - String form (CVM, CBS, Lighthouse, CLB, Postgres): values like "PREPAID" +// / "POSTPAID_BY_HOUR" / "SPOTPAID" / "CDHPAID" (CVM/CBS uppercase) or +// "prepaid" / "postpaid" (Postgres lowercase). All non-prepaid strings map +// to "POSTPAID". +// - Integer form, with NO consistent mapping across services: +// CDB PayType: 0=prepaid, 1=postpaid (inverted vs the others) +// Redis BillingMode: 1=prepaid, 0=postpaid +// MongoDB PayMode: 1=prepaid, 0=postpaid +// CynosDB PayMode: 1=prepaid, 0=postpaid +// WAF PayMode: 1=prepaid, 0=postpaid +// +// The helpers below normalize all of these to two canonical strings +// "PREPAID" / "POSTPAID" so the JSON output is uniform across resource types. +// An empty string is returned when the input is nil — the field is then +// omitted from JSON via `omitempty` so callers can tell "we don't know" apart +// from "POSTPAID". + +const ( + billingPrepaid = "PREPAID" + billingPostpaid = "POSTPAID" +) + +func normChargeTypeStr(s *string) string { + if s == nil { + return "" + } + if strings.EqualFold(*s, "PREPAID") { + return billingPrepaid + } + return billingPostpaid +} + +// CDB inverts the convention: 0 means prepaid, 1 means postpaid. +func normPayTypeCDB(v *int64) string { + if v == nil { + return "" + } + if *v == 0 { + return billingPrepaid + } + return billingPostpaid +} + +// Redis / CynosDB: 1=prepaid, 0=postpaid (int64). +func normBillingModeInt64(v *int64) string { + if v == nil { + return "" + } + if *v == 1 { + return billingPrepaid + } + return billingPostpaid +} + +// MongoDB / WAF: 1=prepaid, 0=postpaid (uint64). +func normBillingModeUint64(v *uint64) string { + if v == nil { + return "" + } + if *v == 1 { + return billingPrepaid + } + return billingPostpaid +} + +// Auto-renew normalizers. Tencent expresses "is this resource set to renew +// itself?" with the same kind of inconsistency as billing mode: +// +// - String form (CVM, CBS, Lighthouse): "NOTIFY_AND_AUTO_RENEW" / +// "NOTIFY_AND_MANUAL_RENEW" / "DISABLE_NOTIFY_AND_MANUAL_RENEW". +// Only the first means "will auto-renew". +// - String form (CLB, nested in PrepaidAttributes): "AUTO_RENEW" / +// "MANUAL_RENEW". +// - Integer form (CDB AutoRenew, Redis AutoRenewFlag, CynosDB RenewFlag): +// 0=manual, 1=auto, 2=cancelled. Only 1 means "will auto-renew". +// - Integer form (Postgres AutoRenew *uint64): same 0/1/2 convention. +// +// All helpers return *bool so the JSON field can be `omitempty` — the +// caller's cron uses missing-field semantics to skip resources where the +// renew state is unknown. nil input → nil output (field omitted). +func boolPtr(b bool) *bool { return &b } + +func normRenewFlagAutoStr(s *string) *bool { + if s == nil { + return nil + } + // "NOTIFY_AND_AUTO_RENEW" (CVM/CBS/Lighthouse) and "AUTO_RENEW" (CLB). + return boolPtr(strings.EqualFold(*s, "NOTIFY_AND_AUTO_RENEW") || strings.EqualFold(*s, "AUTO_RENEW")) +} + +func normAutoRenewInt64(v *int64) *bool { + if v == nil { + return nil + } + return boolPtr(*v == 1) +} + +func normAutoRenewUint64(v *uint64) *bool { + if v == nil { + return nil + } + return boolPtr(*v == 1) +} diff --git a/internal/tencent/clb.go b/internal/tencent/clb.go new file mode 100644 index 0000000..38e2d9c --- /dev/null +++ b/internal/tencent/clb.go @@ -0,0 +1,127 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + clb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb/v20180317" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// listCLBs prints every Cloud Load Balancer in the given regions. +func listCLBs(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + lb *clb.LoadBalancer + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newCLBClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init clb client: %v", r, err)) + continue + } + req := clb.NewDescribeLoadBalancersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeLoadBalancers(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, lb := range resp.Response.LoadBalancerSet { + rows = append(rows, row{region: r, lb: lb}) + } + } + + header := fmt.Sprintf("Cloud Load Balancers (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Cloud Load Balancers (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No CLB instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tLB_ID\tNAME\tTYPE\tSTATUS\tVIPS\tVPC_ID\tCREATED") + } else { + fmt.Fprintln(tw, "LB_ID\tNAME\tTYPE\tSTATUS\tVIPS\tVPC_ID\tCREATED") + } + for _, r := range rows { + lb := r.lb + fields := []string{ + derefString(lb.LoadBalancerId), + derefString(lb.LoadBalancerName), + derefString(lb.LoadBalancerType), + clbStatus(lb.Status), + joinIPs(lb.LoadBalancerVips), + derefString(lb.VpcId), + derefString(lb.CreateTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newCLBClient(c *Client, region string) (*clb.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("clb.tencentcloudapi.com") + return clb.NewClient(cred, region, cpf) +} + +func clbStatus(p *uint64) string { + if p == nil { + return "-" + } + if *p == 0 { + return "CREATING" + } + if *p == 1 { + return "RUNNING" + } + return fmt.Sprintf("STATE-%d", *p) +} + +// fetchCLBListeners pulls the listener set for one LB. Used by the +// public-exposure audit so we can flag CLBs that have open listeners on +// sensitive ports. +func fetchCLBListeners(c *Client, region, lbID string) ([]*clb.Listener, error) { + client, err := newCLBClient(c, region) + if err != nil { + return nil, err + } + req := clb.NewDescribeListenersRequest() + req.LoadBalancerId = &lbID + resp, err := client.DescribeListeners(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + return resp.Response.Listeners, nil +} diff --git a/internal/tencent/client.go b/internal/tencent/client.go new file mode 100644 index 0000000..c5e80e9 --- /dev/null +++ b/internal/tencent/client.go @@ -0,0 +1,193 @@ +package tencent + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/viper" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +const defaultRegion = "ap-singapore" + +// Credentials holds the resolved Tencent Cloud credentials and target region. +// +// SecretKey is redacted in every String() / %v / %+v / json.Marshal output — +// the raw bytes only flow into the SDK's signature path. Add new fields with +// the same care: anything secret-shaped MUST be excluded from the redacted +// shape below. +type Credentials struct { + SecretID string + SecretKey string + Region string +} + +// String renders Credentials with SecretKey redacted. Reached by any %v / +// %+v / Println formatting — including accidental logs of a Client (which +// embeds Credentials). +func (c Credentials) String() string { + return fmt.Sprintf("{SecretID:%s SecretKey:**** Region:%s}", c.SecretID, c.Region) +} + +// MarshalJSON ensures SecretKey is never serialised verbatim. Returns the +// same shape as String() so dashboards and debug endpoints can safely +// json.Marshal a Credentials value. +func (c Credentials) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + SecretID string `json:"secret_id"` + SecretKey string `json:"secret_key"` + Region string `json:"region"` + }{ + SecretID: c.SecretID, + SecretKey: "****", + Region: c.Region, + }) +} + +// Client wraps Tencent Cloud SDK clients scoped to a region. +type Client struct { + creds Credentials + debug bool +} + +// ResolveCredentials reads Tencent credentials from config first, then env. +// Env vars use the names the official Tencent SDK already recognises so the +// same shell session that runs `tccli` will work without extra setup. +func ResolveCredentials() Credentials { + c := Credentials{ + SecretID: strings.TrimSpace(viper.GetString("tencent.secret_id")), + SecretKey: strings.TrimSpace(viper.GetString("tencent.secret_key")), + Region: strings.TrimSpace(viper.GetString("tencent.region")), + } + if c.SecretID == "" { + c.SecretID = firstNonEmpty(os.Getenv("TENCENTCLOUD_SECRET_ID"), os.Getenv("TENCENT_SECRET_ID")) + } + if c.SecretKey == "" { + c.SecretKey = firstNonEmpty(os.Getenv("TENCENTCLOUD_SECRET_KEY"), os.Getenv("TENCENT_SECRET_KEY")) + } + if c.Region == "" { + c.Region = firstNonEmpty(os.Getenv("TENCENTCLOUD_REGION"), os.Getenv("TENCENT_REGION")) + } + if c.Region == "" { + c.Region = defaultRegion + } + return c +} + +// NewClient validates credentials and returns a Tencent client ready to spawn +// per-service SDK clients. +func NewClient(creds Credentials, debug bool) (*Client, error) { + if creds.SecretID == "" || creds.SecretKey == "" { + return nil, fmt.Errorf("tencent credentials are required (set tencent.secret_id/tencent.secret_key, or TENCENTCLOUD_SECRET_ID/TENCENTCLOUD_SECRET_KEY)") + } + if creds.Region == "" { + creds.Region = defaultRegion + } + return &Client{creds: creds, debug: debug}, nil +} + +// BackendTencentCredentials represents Tencent Cloud credentials retrieved +// from the backend credential store (clanker-backend), matching the shape +// every other provider's backend-creds struct has (AWS, GCP, Fly.io, etc.). +type BackendTencentCredentials struct { + SecretID string + SecretKey string + Region string +} + +// NewClientWithCredentials constructs a Tencent client from backend-provided +// credentials. Mirrors NewClientWithCredentials on the other providers so +// the backend wiring layer can dispatch by provider name without special- +// casing Tencent. Not yet wired into the backend credential flow — kept +// for parity until the backend learns to issue Tencent credentials. +func NewClientWithCredentials(creds *BackendTencentCredentials, debug bool) (*Client, error) { + if creds == nil { + return nil, fmt.Errorf("credentials cannot be nil") + } + if strings.TrimSpace(creds.SecretID) == "" || strings.TrimSpace(creds.SecretKey) == "" { + return nil, fmt.Errorf("tencent secret_id and secret_key are required") + } + region := strings.TrimSpace(creds.Region) + if region == "" { + region = defaultRegion + } + return &Client{ + creds: Credentials{ + SecretID: creds.SecretID, + SecretKey: creds.SecretKey, + Region: region, + }, + debug: debug, + }, nil +} + +// Region returns the region this client is targeting. +func (c *Client) Region() string { return c.creds.Region } + +// WithRegion returns a shallow copy of the client scoped to a different region. +// Used by --all-regions fan-out so each region call gets its own SDK client. +func (c *Client) WithRegion(region string) *Client { + clone := *c + clone.creds.Region = strings.TrimSpace(region) + if clone.creds.Region == "" { + clone.creds.Region = defaultRegion + } + return &clone +} + +// ListAllRegions queries Tencent for the full set of CVM regions available to +// this credential. The CVM service is used because every region exposes it and +// the API call is cheap. +func (c *Client) ListAllRegions() ([]string, error) { + cli, err := c.CVM() + if err != nil { + return nil, fmt.Errorf("init cvm client for regions: %w", err) + } + req := cvm.NewDescribeRegionsRequest() + resp, err := cli.DescribeRegions(req) + if err != nil { + return nil, fmt.Errorf("DescribeRegions: %w", friendlyError(err)) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + var out []string + for _, r := range resp.Response.RegionSet { + if r == nil || r.Region == nil { + continue + } + // Skip regions in non-available state (e.g. UNAVAILABLE = closed for new accounts). + if r.RegionState != nil && strings.EqualFold(*r.RegionState, "UNAVAILABLE") { + continue + } + out = append(out, *r.Region) + } + return out, nil +} + +// CVM returns a region-scoped CVM SDK client. +func (c *Client) CVM() (*cvm.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cvm.tencentcloudapi.com") + return cvm.NewClient(cred, c.creds.Region, cpf) +} + +// VPC returns a region-scoped VPC SDK client (also serves subnets + SGs). +func (c *Client) VPC() (*vpc.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("vpc.tencentcloudapi.com") + return vpc.NewClient(cred, c.creds.Region, cpf) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if t := strings.TrimSpace(v); t != "" { + return t + } + } + return "" +} diff --git a/internal/tencent/context.go b/internal/tencent/context.go new file mode 100644 index 0000000..a7af770 --- /dev/null +++ b/internal/tencent/context.go @@ -0,0 +1,1529 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + cdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb/v20170320" + monitor "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor/v20180724" + cls "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls/v20201016" + cloudaudit "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit/v20190319" + dc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc/v20180410" + antiddos "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos/v20200309" + waf "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf/v20180125" + teo "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo/v20220901" + cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606" + cynosdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb/v20190107" + mongodb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb/v20190725" + redis "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis/v20180412" + ssl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + cam "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam/v20190116" + cbs "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs/v20170312" + clb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb/v20180317" + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" + postgres "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres/v20170312" + tke "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke/v20180525" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" + cos "github.com/tencentyun/cos-go-sdk-v5" +) + +// GetRelevantContext gathers Tencent Cloud inventory data shaped for inclusion +// in an LLM prompt. The question is used as a coarse keyword filter — only +// resource types that look relevant are fetched, with CVMs always included. +// +// Returns a multi-section text blob. Errors per section are collected as +// warnings rather than aborting the whole gather; the LLM is better off with +// partial context than nothing. +func (c *Client) GetRelevantContext(ctx context.Context, question string) (string, error) { + q := strings.ToLower(strings.TrimSpace(question)) + + type section struct { + name string + keys []string + run func() (string, error) + } + + sections := []section{ + { + name: "CVMInstances", + keys: nil, // always include + run: func() (string, error) { return c.contextCVMs(ctx) }, + }, + { + name: "VPCs", + keys: []string{"vpc", "network", "subnet", "cidr"}, + run: func() (string, error) { return c.contextVPCs(ctx) }, + }, + { + name: "SecurityGroups", + keys: []string{"security", "firewall", "sg", "port", "expose", "public", "risky", "audit"}, + run: func() (string, error) { return c.contextSecurityGroups(ctx) }, + }, + { + name: "MySQLInstances", + keys: []string{"mysql", "cdb", "db", "database", "rds"}, + run: func() (string, error) { return c.contextMySQL(ctx) }, + }, + { + name: "PostgresInstances", + keys: []string{"postgres", "postgresql", "pg", "db", "database", "rds"}, + run: func() (string, error) { return c.contextPostgres(ctx) }, + }, + { + name: "COSBuckets", + keys: []string{"cos", "bucket", "buckets", "storage", "object", "s3"}, + run: func() (string, error) { return c.contextCOS(ctx) }, + }, + { + name: "TKEClusters", + keys: []string{"tke", "kubernetes", "k8s", "cluster", "clusters", "pod", "node"}, + run: func() (string, error) { return c.contextTKE(ctx) }, + }, + { + name: "CLBs", + keys: []string{"clb", "load", "balancer", "lb"}, + run: func() (string, error) { return c.contextCLB(ctx) }, + }, + { + name: "EIPs", + keys: []string{"eip", "public", "address", "ip"}, + run: func() (string, error) { return c.contextEIP(ctx) }, + }, + { + name: "CBSVolumes", + keys: []string{"cbs", "disk", "volume", "storage", "encrypted"}, + run: func() (string, error) { return c.contextCBS(ctx) }, + }, + { + name: "SSLCertificates", + keys: []string{"ssl", "cert", "tls", "https", "expiry"}, + run: func() (string, error) { return c.contextSSL(ctx) }, + }, + { + name: "CAMUsers", + keys: []string{"cam", "iam", "user", "subaccount", "mfa", "identity"}, + run: func() (string, error) { return c.contextCAM(ctx) }, + }, + { + name: "RedisInstances", + keys: []string{"redis", "valkey", "cache"}, + run: func() (string, error) { return c.contextRedis(ctx) }, + }, + { + name: "MongoDBInstances", + keys: []string{"mongo", "mongodb", "document"}, + run: func() (string, error) { return c.contextMongoDB(ctx) }, + }, + { + name: "CynosDBClusters", + keys: []string{"cynosdb", "tdsql-c", "serverless"}, + run: func() (string, error) { return c.contextCynosDB(ctx) }, + }, + { + name: "CDNDomains", + keys: []string{"cdn", "edge", "cache"}, + run: func() (string, error) { return c.contextCDN(ctx) }, + }, + { + name: "EdgeOneZones", + keys: []string{"edgeone", "teo", "zone"}, + run: func() (string, error) { return c.contextEdgeOne(ctx) }, + }, + { + name: "WAFHosts", + keys: []string{"waf", "firewall", "shield"}, + run: func() (string, error) { return c.contextWAF(ctx) }, + }, + { + name: "AntiDDoSInstances", + keys: []string{"ddos", "antiddos", "attack"}, + run: func() (string, error) { return c.contextAntiDDoS(ctx) }, + }, + { + name: "NATGateways", + keys: []string{"nat", "egress", "outbound"}, + run: func() (string, error) { return c.contextNAT(ctx) }, + }, + { + name: "VPNGateways", + keys: []string{"vpn", "tunnel", "ipsec"}, + run: func() (string, error) { return c.contextVPN(ctx) }, + }, + { + name: "CCNs", + keys: []string{"ccn", "interconnect", "cloud-connect"}, + run: func() (string, error) { return c.contextCCN(ctx) }, + }, + { + name: "DirectConnects", + keys: []string{"dc", "direct-connect", "leased-line"}, + run: func() (string, error) { return c.contextDC(ctx) }, + }, + { + name: "AlarmPolicies", + keys: []string{"alarm", "alert", "monitor"}, + run: func() (string, error) { return c.contextAlarmPolicies(ctx) }, + }, + { + name: "CLSTopics", + keys: []string{"cls", "log", "logs"}, + run: func() (string, error) { return c.contextCLSTopics(ctx) }, + }, + { + name: "CloudAuditTracks", + keys: []string{"audit", "cloudaudit", "track", "compliance"}, + run: func() (string, error) { return c.contextCloudAudit(ctx) }, + }, + } + + var out strings.Builder + out.WriteString(fmt.Sprintf("Region: %s\n\n", c.Region())) + + var warnings []string + for _, s := range sections { + if len(s.keys) > 0 && q != "" { + matched := false + for _, k := range s.keys { + if strings.Contains(q, k) { + matched = true + break + } + } + if !matched { + continue + } + } + body, err := s.run() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", s.name, err)) + continue + } + if strings.TrimSpace(body) == "" { + continue + } + out.WriteString(s.name) + out.WriteString(":\n") + out.WriteString(body) + out.WriteString("\n\n") + } + + if len(warnings) > 0 { + out.WriteString("Warnings:\n") + for _, w := range warnings { + out.WriteString("- ") + out.WriteString(w) + out.WriteString("\n") + } + } + + if strings.TrimSpace(out.String()) == "" { + return "No Tencent Cloud data available in this region.", nil + } + return out.String(), nil +} + +// contextCVMs returns a compact JSON array of CVMs in this client's region. +// JSON keeps the LLM's parser happy while remaining token-efficient compared +// to the verbose SDK struct. +func (c *Client) contextCVMs(ctx context.Context) (string, error) { + client, err := c.CVM() + if err != nil { + return "", err + } + + type instSummary struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Type string `json:"type"` + Zone string `json:"zone"` + PrivateIP []string `json:"private_ip,omitempty"` + PublicIP []string `json:"public_ip,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + OSName string `json:"os,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + } + var slim []instSummary + var offset, limit int64 = 0, gatherPageSize + for { + if err := ctxDone(ctx); err != nil { + return "", err + } + req := cvm.NewDescribeInstancesRequest() + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + for _, in := range resp.Response.InstanceSet { + slim = append(slim, instSummary{ + ID: derefStringRaw(in.InstanceId), + Name: derefStringRaw(in.InstanceName), + State: derefStringRaw(in.InstanceState), + Type: derefStringRaw(in.InstanceType), + Zone: derefStringRaw(in.Placement.Zone), + PrivateIP: stringSlice(in.PrivateIpAddresses), + PublicIP: stringSlice(in.PublicIpAddresses), + CreatedAt: derefStringRaw(in.CreatedTime), + ExpiresAt: derefStringRaw(in.ExpiredTime), + BillingMode: normChargeTypeStr(in.InstanceChargeType), + AutoRenew: normRenewFlagAutoStr(in.RenewFlag), + OSName: derefStringRaw(in.OsName), + Tags: extractTags(in.Tags), + }) + } + total := derefInt64(resp.Response.TotalCount) + offset += int64(len(resp.Response.InstanceSet)) + if offset >= total || len(resp.Response.InstanceSet) == 0 { + break + } + if len(slim) >= gatherMaxItems { + logGatherTruncated("CVM", c.creds.Region, total, len(slim)) + break + } + } + if len(slim) == 0 { + return "", nil + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextVPCs(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + type vpcSummary struct { + ID string `json:"id"` + Name string `json:"name"` + CIDR string `json:"cidr"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + } + var slim []vpcSummary + offsetStr, limitStr := "0", strconv.FormatInt(gatherPageSize, 10) + for { + if err := ctxDone(ctx); err != nil { + return "", err + } + req := vpc.NewDescribeVpcsRequest() + req.Offset = &offsetStr + req.Limit = &limitStr + resp, err := client.DescribeVpcs(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + for _, v := range resp.Response.VpcSet { + slim = append(slim, vpcSummary{ + ID: derefStringRaw(v.VpcId), + Name: derefStringRaw(v.VpcName), + CIDR: derefStringRaw(v.CidrBlock), + IsDefault: derefBool(v.IsDefault), + CreatedAt: derefStringRaw(v.CreatedTime), + Tags: extractTags(v.TagSet), + }) + } + total := derefUint64Raw(resp.Response.TotalCount) + if uint64(len(slim)) >= total || len(resp.Response.VpcSet) == 0 { + break + } + if len(slim) >= gatherMaxItems { + logGatherTruncated("VPC", c.creds.Region, int64(total), len(slim)) + break + } + offsetStr = strconv.FormatInt(int64(len(slim)), 10) + } + if len(slim) == 0 { + return "", nil + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + + +func (c *Client) contextSecurityGroups(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + type sgSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsDefault bool `json:"is_default"` + } + var slim []sgSummary + offsetStr, limitStr := "0", strconv.FormatInt(gatherPageSize, 10) + for { + if err := ctxDone(ctx); err != nil { + return "", err + } + req := vpc.NewDescribeSecurityGroupsRequest() + req.Offset = &offsetStr + req.Limit = &limitStr + resp, err := client.DescribeSecurityGroups(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + for _, g := range resp.Response.SecurityGroupSet { + slim = append(slim, sgSummary{ + ID: derefStringRaw(g.SecurityGroupId), + Name: derefStringRaw(g.SecurityGroupName), + Description: derefStringRaw(g.SecurityGroupDesc), + IsDefault: derefBool(g.IsDefault), + }) + } + total := derefUint64Raw(resp.Response.TotalCount) + if uint64(len(slim)) >= total || len(resp.Response.SecurityGroupSet) == 0 { + break + } + if len(slim) >= gatherMaxItems { + logGatherTruncated("SecurityGroup", c.creds.Region, int64(total), len(slim)) + break + } + offsetStr = strconv.FormatInt(int64(len(slim)), 10) + } + if len(slim) == 0 { + return "", nil + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextMySQL(ctx context.Context) (string, error) { + client, err := newCDBClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := cdb.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDBInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Items) == 0 { + return "", nil + } + type mysqlSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Engine string `json:"engine"` + MemoryMB int64 `json:"memory_mb,omitempty"` + VolumeGB int64 `json:"volume_gb,omitempty"` + Zone string `json:"zone,omitempty"` + PrivateIP string `json:"private_ip,omitempty"` + PrivatePort int64 `json:"private_port,omitempty"` + PublicAddr string `json:"public_addr,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + } + var slim []mysqlSummary + for _, i := range resp.Response.Items { + s := mysqlSummary{ + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: mysqlStatus(i.Status), + Engine: derefStringRaw(i.EngineVersion), + MemoryMB: derefInt64Raw(i.Memory), + VolumeGB: derefInt64Raw(i.Volume), + Zone: derefStringRaw(i.Zone), + PrivateIP: derefStringRaw(i.Vip), + PrivatePort: derefInt64Raw(i.Vport), + ExpiresAt: derefStringRaw(i.DeadlineTime), + BillingMode: normPayTypeCDB(i.PayType), + AutoRenew: normAutoRenewInt64(i.AutoRenew), + // MySQL tags are not on DescribeDBInstances — they require a + // separate DescribeTagsOfInstanceIds call. Left as a TODO. + } + if i.WanStatus != nil && *i.WanStatus == 1 { + s.PublicAddr = fmt.Sprintf("%s:%d", derefStringRaw(i.WanDomain), derefInt64Raw(i.WanPort)) + } + slim = append(slim, s) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextPostgres(ctx context.Context) (string, error) { + client, err := newPostgresClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := postgres.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDBInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.DBInstanceSet) == 0 { + return "", nil + } + type pgSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Engine string `json:"engine"` + CPU uint64 `json:"cpu,omitempty"` + MemoryGB uint64 `json:"memory_gb,omitempty"` + StorageGB uint64 `json:"storage_gb,omitempty"` + Zone string `json:"zone,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + } + var slim []pgSummary + for _, i := range resp.Response.DBInstanceSet { + slim = append(slim, pgSummary{ + ID: derefStringRaw(i.DBInstanceId), + Name: derefStringRaw(i.DBInstanceName), + Status: derefStringRaw(i.DBInstanceStatus), + Engine: derefStringRaw(i.DBVersion), + CPU: derefUint64Raw(i.DBInstanceCpu), + MemoryGB: derefUint64Raw(i.DBInstanceMemory), + StorageGB: derefUint64Raw(i.DBInstanceStorage), + Zone: derefStringRaw(i.Zone), + CreatedAt: derefStringRaw(i.CreateTime), + ExpiresAt: derefStringRaw(i.ExpireTime), + BillingMode: normChargeTypeStr(i.PayType), + AutoRenew: normAutoRenewUint64(i.AutoRenew), + Tags: extractTags(i.TagList), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCOS(ctx context.Context) (string, error) { + client := cos.NewClient(nil, &http.Client{ + Timeout: 30 * time.Second, + Transport: &cos.AuthorizationTransport{ + SecretID: c.creds.SecretID, + SecretKey: c.creds.SecretKey, + }, + }) + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + resp, _, err := client.Service.Get(cctx) + if err != nil { + return "", fmt.Errorf("cos service get: %w", err) + } + if resp == nil || len(resp.Buckets) == 0 { + return "", nil + } + type bucketSummary struct { + Name string `json:"name"` + Region string `json:"region"` + CreatedAt string `json:"created_at,omitempty"` + Type string `json:"type,omitempty"` + } + var slim []bucketSummary + for _, b := range resp.Buckets { + slim = append(slim, bucketSummary{ + Name: b.Name, + Region: b.Region, + CreatedAt: b.CreationDate, + Type: b.BucketType, + }) + } + out, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(out), nil +} + +func (c *Client) contextTKE(ctx context.Context) (string, error) { + client, err := newTKEClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := tke.NewDescribeClustersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeClusters(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Clusters) == 0 { + return "", nil + } + type tkeSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Version string `json:"k8s_version"` + Type string `json:"type"` + NodeNum uint64 `json:"node_num,omitempty"` + VpcID string `json:"vpc_id,omitempty"` + Created string `json:"created_at,omitempty"` + } + var slim []tkeSummary + for _, cl := range resp.Response.Clusters { + vpcID := "" + if cl.ClusterNetworkSettings != nil && cl.ClusterNetworkSettings.VpcId != nil { + vpcID = *cl.ClusterNetworkSettings.VpcId + } + slim = append(slim, tkeSummary{ + ID: derefStringRaw(cl.ClusterId), + Name: derefStringRaw(cl.ClusterName), + Status: derefStringRaw(cl.ClusterStatus), + Version: derefStringRaw(cl.ClusterVersion), + Type: derefStringRaw(cl.ClusterType), + NodeNum: derefUint64Raw(cl.ClusterNodeNum), + VpcID: vpcID, + Created: derefStringRaw(cl.CreatedTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func derefInt64Raw(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} + +func derefUint64Raw(p *uint64) uint64 { + if p == nil { + return 0 + } + return *p +} + +// Append to internal/tencent/context.go via the patcher. These reuse the +// existing region-scoped client from c, mirror the slim JSON shape we use +// for other resource types, and surface only the columns the dashboard or +// LLM cares about. + +func (c *Client) contextCLB(ctx context.Context) (string, error) { + client, err := newCLBClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := clb.NewDescribeLoadBalancersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeLoadBalancers(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.LoadBalancerSet) == 0 { + return "", nil + } + type lbSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + VIPs []string `json:"vips,omitempty"` + VpcID string `json:"vpc_id,omitempty"` + Created string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + } + var slim []lbSummary + for _, lb := range resp.Response.LoadBalancerSet { + // CLB nests the renew flag inside PrepaidAttributes — only set + // when the LB is actually prepaid. The flag value uses "AUTO_RENEW" + // / "MANUAL_RENEW" (no NOTIFY_AND_ prefix unlike CVM). + var renew *string + if lb.PrepaidAttributes != nil { + renew = lb.PrepaidAttributes.RenewFlag + } + slim = append(slim, lbSummary{ + ID: derefStringRaw(lb.LoadBalancerId), + Name: derefStringRaw(lb.LoadBalancerName), + Type: derefStringRaw(lb.LoadBalancerType), + Status: clbStatus(lb.Status), + VIPs: stringSlice(lb.LoadBalancerVips), + VpcID: derefStringRaw(lb.VpcId), + Created: derefStringRaw(lb.CreateTime), + ExpiresAt: derefStringRaw(lb.ExpireTime), + BillingMode: normChargeTypeStr(lb.ChargeType), + AutoRenew: normRenewFlagAutoStr(renew), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextEIP(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + req := vpc.NewDescribeAddressesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeAddresses(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.AddressSet) == 0 { + return "", nil + } + type eipSummary struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + IP string `json:"ip"` + Status string `json:"status"` + Type string `json:"type,omitempty"` + InstanceID string `json:"instance_id,omitempty"` + Created string `json:"created_at,omitempty"` + } + var slim []eipSummary + for _, a := range resp.Response.AddressSet { + slim = append(slim, eipSummary{ + ID: derefStringRaw(a.AddressId), + Name: derefStringRaw(a.AddressName), + IP: derefStringRaw(a.AddressIp), + Status: derefStringRaw(a.AddressStatus), + Type: derefStringRaw(a.AddressType), + InstanceID: derefStringRaw(a.InstanceId), + Created: derefStringRaw(a.CreatedTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCBS(ctx context.Context) (string, error) { + client, err := newCBSClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := cbs.NewDescribeDisksRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDisks(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.DiskSet) == 0 { + return "", nil + } + type diskSummary struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type"` + SizeGB uint64 `json:"size_gb"` + State string `json:"state"` + Encrypted bool `json:"encrypted"` + InstanceID string `json:"instance_id,omitempty"` + Zone string `json:"zone,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + } + var slim []diskSummary + for _, d := range resp.Response.DiskSet { + zone := "" + if d.Placement != nil { + zone = derefStringRaw(d.Placement.Zone) + } + slim = append(slim, diskSummary{ + ID: derefStringRaw(d.DiskId), + Name: derefStringRaw(d.DiskName), + Type: derefStringRaw(d.DiskType), + SizeGB: derefUint64Raw(d.DiskSize), + State: derefStringRaw(d.DiskState), + Encrypted: derefBool(d.Encrypt), + InstanceID: derefStringRaw(d.InstanceId), + Zone: zone, + ExpiresAt: derefStringRaw(d.DeadlineTime), + BillingMode: normChargeTypeStr(d.DiskChargeType), + AutoRenew: normRenewFlagAutoStr(d.RenewFlag), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextSSL(ctx context.Context) (string, error) { + client, err := newSSLClient(c) + if err != nil { + return "", err + } + req := ssl.NewDescribeCertificatesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeCertificates(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Certificates) == 0 { + return "", nil + } + type certSummary struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Domain string `json:"domain,omitempty"` + Status string `json:"status"` + From string `json:"from,omitempty"` + CertEnd string `json:"cert_end,omitempty"` + DaysLeft int `json:"days_left"` + } + var slim []certSummary + for _, cert := range resp.Response.Certificates { + slim = append(slim, certSummary{ + ID: derefStringRaw(cert.CertificateId), + Alias: derefStringRaw(cert.Alias), + Domain: derefStringRaw(cert.Domain), + Status: sslStatus(cert.Status), + From: derefStringRaw(cert.From), + CertEnd: derefStringRaw(cert.CertEndTime), + DaysLeft: daysUntilExpiry(cert.CertEndTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCAM(ctx context.Context) (string, error) { + client, err := newCAMClient(c) + if err != nil { + return "", err + } + resp, err := client.ListUsers(cam.NewListUsersRequest()) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Data) == 0 { + return "", nil + } + type userSummary struct { + UID uint64 `json:"uid"` + Name string `json:"name"` + NickName string `json:"nickname,omitempty"` + Email string `json:"email,omitempty"` + ConsoleLogin bool `json:"console_login"` + PhoneSet bool `json:"phone_set"` + Created string `json:"created_at,omitempty"` + } + var slim []userSummary + for _, u := range resp.Response.Data { + phone := derefStringRaw(u.PhoneNum) + slim = append(slim, userSummary{ + UID: derefUint64Raw(u.Uid), + Name: derefStringRaw(u.Name), + NickName: derefStringRaw(u.NickName), + Email: derefStringRaw(u.Email), + ConsoleLogin: derefUint64Raw(u.ConsoleLogin) == 1, + PhoneSet: phone != "", + Created: derefStringRaw(u.CreateTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + + +func (c *Client) contextRedis(ctx context.Context) (string, error) { + client, err := newRedisClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := redis.NewDescribeInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.InstanceSet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + MemoryMB int64 `json:"memory_mb,omitempty"` + Vip string `json:"vip,omitempty"` + Port int64 `json:"port,omitempty"` + PublicAddr string `json:"public_addr,omitempty"` + Created string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + } + var slim []s + for _, i := range resp.Response.InstanceSet { + size := int64(0) + if i.Size != nil { + size = int64(*i.Size) + } + slim = append(slim, s{ + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: redisStatus(i.Status), + MemoryMB: size, + Vip: derefStringRaw(i.WanIp), + Port: derefInt64Raw(i.Port), + PublicAddr: derefStringRaw(i.WanAddress), + Created: derefStringRaw(i.Createtime), + ExpiresAt: derefStringRaw(i.DeadlineTime), + BillingMode: normBillingModeInt64(i.BillingMode), + AutoRenew: normAutoRenewInt64(i.AutoRenewFlag), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextMongoDB(ctx context.Context) (string, error) { + client, err := newMongoDBClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := mongodb.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDBInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.InstanceDetails) == 0 { + return "", nil + } + // Note: Tencent's MongoDB DescribeDBInstances returns PayMode but not a + // DeadlineTime/ExpiredTime field on MongoDBInstanceDetail — the per-instance + // expiry is only available via the renewal API. We surface billing_mode + // alone here so the consistency with other resources is preserved. + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + ClusterType string `json:"cluster_type"` + Vip string `json:"vip,omitempty"` + Port uint64 `json:"port,omitempty"` + Zone string `json:"zone,omitempty"` + Created string `json:"created_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + } + var slim []s + for _, i := range resp.Response.InstanceDetails { + slim = append(slim, s{ + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: mongoStatus(i.Status), + ClusterType: mongoClusterType(i.ClusterType), + Vip: derefStringRaw(i.Vip), + Port: derefUint64Raw(i.Vport), + Zone: derefStringRaw(i.Zone), + Created: derefStringRaw(i.CreateTime), + BillingMode: normBillingModeUint64(i.PayMode), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCynosDB(ctx context.Context) (string, error) { + client, err := newCynosDBClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := cynosdb.NewDescribeClustersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeClusters(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.ClusterSet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + Engine string `json:"engine,omitempty"` + DBVersion string `json:"db_version,omitempty"` + InstanceNum int64 `json:"instance_num"` + Zone string `json:"zone,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + } + var slim []s + for _, cl := range resp.Response.ClusterSet { + slim = append(slim, s{ + ID: derefStringRaw(cl.ClusterId), + Name: derefStringRaw(cl.ClusterName), + Status: derefStringRaw(cl.Status), + Engine: derefStringRaw(cl.DbType), + DBVersion: derefStringRaw(cl.DbVersion), + InstanceNum: derefInt64Raw(cl.InstanceNum), + Zone: derefStringRaw(cl.Zone), + ExpiresAt: derefStringRaw(cl.PeriodEndTime), + BillingMode: normBillingModeInt64(cl.PayMode), + AutoRenew: normAutoRenewInt64(cl.RenewFlag), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + + +func (c *Client) contextCDN(ctx context.Context) (string, error) { + client, err := newCDNClient(c) + if err != nil { + return "", err + } + req := cdn.NewDescribeDomainsRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDomains(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Domains) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Domain string `json:"domain"` + CName string `json:"cname,omitempty"` + Status string `json:"status"` + Service string `json:"service,omitempty"` + Created string `json:"created_at,omitempty"` + } + var slim []s + for _, d := range resp.Response.Domains { + slim = append(slim, s{ + ID: derefStringRaw(d.ResourceId), + Domain: derefStringRaw(d.Domain), + CName: derefStringRaw(d.Cname), + Status: derefStringRaw(d.Status), + Service: derefStringRaw(d.ServiceType), + Created: derefStringRaw(d.CreateTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextEdgeOne(ctx context.Context) (string, error) { + client, err := newEdgeOneClient(c) + if err != nil { + return "", err + } + req := teo.NewDescribeZonesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeZones(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Zones) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type,omitempty"` + Area string `json:"area,omitempty"` + Status string `json:"status,omitempty"` + } + var slim []s + for _, z := range resp.Response.Zones { + slim = append(slim, s{ + ID: derefStringRaw(z.ZoneId), + Name: derefStringRaw(z.ZoneName), + Type: derefStringRaw(z.Type), + Area: derefStringRaw(z.Area), + Status: derefStringRaw(z.Status), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextWAF(ctx context.Context) (string, error) { + client, err := newWAFClient(c) + if err != nil { + return "", err + } + resp, err := client.DescribeHosts(waf.NewDescribeHostsRequest()) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || resp.Response.HostList == nil || len(resp.Response.HostList) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Domain string `json:"domain"` + MainDomain string `json:"main_domain,omitempty"` + Mode string `json:"mode,omitempty"` + Status string `json:"status,omitempty"` + } + var slim []s + for _, h := range resp.Response.HostList { + slim = append(slim, s{ + ID: derefStringRaw(h.DomainId), + Domain: derefStringRaw(h.Domain), + MainDomain: derefStringRaw(h.MainDomain), + Mode: wafMode(h.Mode), + Status: wafStatus(h.Status), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextAntiDDoS(ctx context.Context) (string, error) { + client, err := newAntiDDoSClient(c) + if err != nil { + return "", err + } + req := antiddos.NewDescribeListBGPIPInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeListBGPIPInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.InstanceList) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + Region string `json:"region,omitempty"` + Created string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + } + var slim []s + for _, i := range resp.Response.InstanceList { + id := "" + if i.InstanceDetail != nil { + id = derefStringRaw(i.InstanceDetail.InstanceId) + } + region := "" + if i.Region != nil { + region = derefStringRaw(i.Region.Region) + } + // AntiDDoS Pro (BGP-IP) is sold as a fixed-term subscription only — + // every instance is implicitly PREPAID. The SDK doesn't expose a + // charge-type field for this product, so we hardcode the value to + // keep the JSON shape consistent with other prepaid resources. + slim = append(slim, s{ + ID: id, + Name: derefStringRaw(i.Name), + Status: derefStringRaw(i.Status), + Region: region, + Created: derefStringRaw(i.CreatedTime), + ExpiresAt: derefStringRaw(i.ExpiredTime), + BillingMode: billingPrepaid, + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + + +func (c *Client) contextNAT(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + req := vpc.NewDescribeNatGatewaysRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeNatGateways(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.NatGatewaySet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + State string `json:"state"` + BandwidthOutMbps uint64 `json:"bandwidth_out_mbps,omitempty"` + PublicIPs []string `json:"public_ips,omitempty"` + Created string `json:"created_at,omitempty"` + } + var slim []s + for _, g := range resp.Response.NatGatewaySet { + var ips []string + for _, ip := range g.PublicIpAddressSet { + if ip != nil && ip.PublicIpAddress != nil { + ips = append(ips, *ip.PublicIpAddress) + } + } + slim = append(slim, s{ + ID: derefStringRaw(g.NatGatewayId), + Name: derefStringRaw(g.NatGatewayName), + State: derefStringRaw(g.State), + BandwidthOutMbps: derefUint64Raw(g.InternetMaxBandwidthOut), + PublicIPs: ips, + Created: derefStringRaw(g.CreatedTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextVPN(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + req := vpc.NewDescribeVpnGatewaysRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeVpnGateways(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.VpnGatewaySet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + State string `json:"state"` + PublicIP string `json:"public_ip,omitempty"` + VpcID string `json:"vpc_id,omitempty"` + } + var slim []s + for _, g := range resp.Response.VpnGatewaySet { + slim = append(slim, s{ + ID: derefStringRaw(g.VpnGatewayId), + Name: derefStringRaw(g.VpnGatewayName), + Type: derefStringRaw(g.Type), + State: derefStringRaw(g.State), + PublicIP: derefStringRaw(g.PublicIpAddress), + VpcID: derefStringRaw(g.VpcId), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCCN(ctx context.Context) (string, error) { + client, err := c.VPC() + if err != nil { + return "", err + } + req := vpc.NewDescribeCcnsRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeCcns(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.CcnSet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + State string `json:"state"` + InstanceCount uint64 `json:"instance_count,omitempty"` + Created string `json:"created_at,omitempty"` + } + var slim []s + for _, ccn := range resp.Response.CcnSet { + slim = append(slim, s{ + ID: derefStringRaw(ccn.CcnId), + Name: derefStringRaw(ccn.CcnName), + State: derefStringRaw(ccn.State), + InstanceCount: derefUint64Raw(ccn.InstanceCount), + Created: derefStringRaw(ccn.CreateTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextDC(ctx context.Context) (string, error) { + client, err := newDCClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := dc.NewDescribeDirectConnectsRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDirectConnects(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.DirectConnectSet) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + State string `json:"state"` + AccessPoint string `json:"access_point,omitempty"` + } + var slim []s + for _, d := range resp.Response.DirectConnectSet { + slim = append(slim, s{ + ID: derefStringRaw(d.DirectConnectId), + Name: derefStringRaw(d.DirectConnectName), + State: derefStringRaw(d.State), + AccessPoint: derefStringRaw(d.AccessPointId), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + + +func (c *Client) contextAlarmPolicies(ctx context.Context) (string, error) { + client, err := newMonitorClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := monitor.NewDescribeAlarmPoliciesRequest() + module := "monitor" + req.Module = &module + var page, pageSize int64 = 1, 100 + req.PageNumber = &page + req.PageSize = &pageSize + resp, err := client.DescribeAlarmPolicies(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Policies) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled"` + MonitorType string `json:"monitor_type,omitempty"` + BoundInstances int64 `json:"bound_instances"` + } + var slim []s + for _, p := range resp.Response.Policies { + slim = append(slim, s{ + ID: derefStringRaw(p.PolicyId), + Name: derefStringRaw(p.PolicyName), + Enabled: derefInt64Raw(p.Enable) == 1, + MonitorType: derefStringRaw(p.MonitorType), + BoundInstances: derefInt64Raw(p.UseSum), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCLSTopics(ctx context.Context) (string, error) { + client, err := newCLSClient(c, c.creds.Region) + if err != nil { + return "", err + } + resp, err := client.DescribeTopics(cls.NewDescribeTopicsRequest()) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.Topics) == 0 { + return "", nil + } + type s struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + LogsetID string `json:"logset_id,omitempty"` + Partitions int64 `json:"partitions"` + Index bool `json:"index"` + Created string `json:"created_at,omitempty"` + } + var slim []s + for _, t := range resp.Response.Topics { + slim = append(slim, s{ + ID: derefStringRaw(t.TopicId), + Name: derefStringRaw(t.TopicName), + LogsetID: derefStringRaw(t.LogsetId), + Partitions: derefInt64Raw(t.PartitionCount), + Index: derefBool(t.Index), + Created: derefStringRaw(t.CreateTime), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) contextCloudAudit(ctx context.Context) (string, error) { + client, err := newCloudAuditClient(c) + if err != nil { + return "", err + } + resp, err := client.ListAudits(cloudaudit.NewListAuditsRequest()) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.AuditSummarys) == 0 { + return "", nil + } + type s struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + COSBucket string `json:"cos_bucket,omitempty"` + Prefix string `json:"log_prefix,omitempty"` + } + var slim []s + for _, a := range resp.Response.AuditSummarys { + slim = append(slim, s{ + Name: derefStringRaw(a.AuditName), + Enabled: derefInt64Raw(a.AuditStatus) == 1, + COSBucket: derefStringRaw(a.CosBucketName), + Prefix: derefStringRaw(a.LogFilePrefix), + }) + } + b, err := json.Marshal(slim) + if err != nil { + return "", err + } + return string(b), nil +} + +// derefStringRaw returns the raw pointer value or empty string — used by +// context builders that want JSON omitempty to actually drop empties (the +// table renderer's "-" placeholder would defeat that). +func derefStringRaw(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + +func stringSlice(ptrs []*string) []string { + if len(ptrs) == 0 { + return nil + } + out := make([]string, 0, len(ptrs)) + for _, p := range ptrs { + if p != nil && *p != "" { + out = append(out, *p) + } + } + return out +} diff --git a/internal/tencent/cos.go b/internal/tencent/cos.go new file mode 100644 index 0000000..4667f0b --- /dev/null +++ b/internal/tencent/cos.go @@ -0,0 +1,50 @@ +package tencent + +import ( + "context" + "fmt" + "net/http" + "os" + "text/tabwriter" + "time" + + cos "github.com/tencentyun/cos-go-sdk-v5" +) + +// listCOSBuckets enumerates every COS bucket the credential can see. +// Unlike CVM/VPC/DB, COS uses an S3-style service endpoint that's not +// region-scoped — the service-level GET returns all buckets the credential +// owns across every region, so multi-region fan-out is not required here. +func listCOSBuckets(c *Client) error { + client := cos.NewClient(nil, &http.Client{ + Timeout: 30 * time.Second, + Transport: &cos.AuthorizationTransport{ + SecretID: c.creds.SecretID, + SecretKey: c.creds.SecretKey, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, _, err := client.Service.Get(ctx) + if err != nil { + return fmt.Errorf("cos service get: %w", err) + } + + fmt.Println("Tencent Cloud Object Storage (COS) Buckets:") + fmt.Println() + if resp == nil || len(resp.Buckets) == 0 { + fmt.Println(" No COS buckets found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tREGION\tCREATED\tTYPE") + for _, b := range resp.Buckets { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + b.Name, b.Region, b.CreationDate, b.BucketType, + ) + } + return tw.Flush() +} diff --git a/internal/tencent/cvm.go b/internal/tencent/cvm.go new file mode 100644 index 0000000..a297414 --- /dev/null +++ b/internal/tencent/cvm.go @@ -0,0 +1,161 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + sdkerrors "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors" + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" +) + +// listCVM prints every CVM instance across the given regions. When more than +// one region is supplied a REGION column is added. +func listCVM(c *Client, regions []string) error { + multi := len(regions) > 1 + + type row struct { + region string + inst *cvm.Instance + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.CVM() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init cvm client: %v", r, err)) + continue + } + req := cvm.NewDescribeInstancesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + for { + resp, err := client.DescribeInstances(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + break + } + if resp == nil || resp.Response == nil { + break + } + for _, inst := range resp.Response.InstanceSet { + rows = append(rows, row{region: r, inst: inst}) + } + total := derefInt64(resp.Response.TotalCount) + offset += int64(len(resp.Response.InstanceSet)) + if offset >= total || len(resp.Response.InstanceSet) == 0 { + break + } + req.Offset = &offset + } + } + + header := fmt.Sprintf("Tencent Cloud CVM Instances (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud CVM Instances (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No CVM instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tINSTANCE_ID\tNAME\tSTATE\tTYPE\tPRIVATE_IP\tPUBLIC_IP\tZONE\tCREATED") + } else { + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATE\tTYPE\tPRIVATE_IP\tPUBLIC_IP\tZONE\tCREATED") + } + for _, r := range rows { + inst := r.inst + fields := []string{ + derefString(inst.InstanceId), + derefString(inst.InstanceName), + derefString(inst.InstanceState), + derefString(inst.InstanceType), + joinIPs(inst.PrivateIpAddresses), + joinIPs(inst.PublicIpAddresses), + derefString(inst.Placement.Zone), + derefString(inst.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func printWarnings(warns []string) { + if len(warns) == 0 { + return + } + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Warnings:") + for _, w := range warns { + fmt.Fprintln(os.Stderr, " -", w) + } +} + +func joinIPs(ptrs []*string) string { + if len(ptrs) == 0 { + return "-" + } + var out []string + for _, p := range ptrs { + if p != nil && *p != "" { + out = append(out, *p) + } + } + if len(out) == 0 { + return "-" + } + return strings.Join(out, ",") +} + +func derefString(s *string) string { + if s == nil { + return "-" + } + v := strings.TrimSpace(*s) + if v == "" { + return "-" + } + return v +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +// friendlyError converts Tencent SDK errors into something users can act on +// without exposing the full SDK wrapper noise. +func friendlyError(err error) error { + if err == nil { + return nil + } + if sdkErr, ok := err.(*sdkerrors.TencentCloudSDKError); ok { + hint := "" + switch sdkErr.Code { + case "AuthFailure", "AuthFailure.SignatureFailure", "AuthFailure.SecretIdNotFound": + hint = " (check TENCENTCLOUD_SECRET_ID/TENCENT_SECRET_ID and matching secret key)" + case "UnauthorizedOperation.CamNoAuth", "UnauthorizedOperation": + hint = " (sub-account is missing CAM permissions for this API)" + } + return fmt.Errorf("[%s] %s%s", sdkErr.Code, sdkErr.Message, hint) + } + return err +} diff --git a/internal/tencent/cvm_metrics.go b/internal/tencent/cvm_metrics.go new file mode 100644 index 0000000..e7d9542 --- /dev/null +++ b/internal/tencent/cvm_metrics.go @@ -0,0 +1,166 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + monitor "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor/v20180724" +) + +// CVMMetricsJSON pulls one metric (default CPUUsage) for every CVM in the +// region over the last N minutes (default 60). One API call per metric is +// made; instances are bundled in a single request via Instances[]. The +// returned data is the latest sample per instance — enough for a "current +// load" snapshot, not for a full sparkline (a future enhancement could +// add a series endpoint). +func (c *Client) CVMMetricsJSON(ctx context.Context, region, metricName string, minutes int) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + if metricName == "" { + metricName = "CPUUsage" + } + if minutes <= 0 || minutes > 60*24 { + minutes = 60 + } + + // First gather the CVM IDs in this region. + cvms, err := c.topoCVMs() + if err != nil { + return "", err + } + if len(cvms) == 0 { + out := struct { + Region string `json:"region"` + Metric string `json:"metric"` + Items []interface{} `json:"items"` + }{c.Region(), metricName, nil} + b, _ := json.Marshal(out) + return string(b), nil + } + + client, err := newMonitorClient(c, c.creds.Region) + if err != nil { + return "", err + } + + now := time.Now() + start := now.Add(-time.Duration(minutes) * time.Minute) + endStr := now.UTC().Format("2006-01-02T15:04:05Z") + startStr := start.UTC().Format("2006-01-02T15:04:05Z") + period := uint64(60) // 1-minute samples + + // Build the instances slice. Tencent's GetMonitorData accepts + // [{InstanceId: }] dimension entries for QCE/CVM namespace. + ns := "QCE/CVM" + req := monitor.NewGetMonitorDataRequest() + req.Namespace = &ns + req.MetricName = &metricName + req.Period = &period + req.StartTime = &startStr + req.EndTime = &endStr + for _, in := range cvms { + id := in.ID + if id == "" { + continue + } + instID := id + dim := &monitor.Instance{ + Dimensions: []*monitor.Dimension{ + {Name: ptrString("InstanceId"), Value: &instID}, + }, + } + req.Instances = append(req.Instances, dim) + } + resp, err := client.GetMonitorData(req) + if err != nil { + return "", fmt.Errorf("GetMonitorData: %w", friendlyError(err)) + } + + type item struct { + InstanceID string `json:"instance_id"` + Name string `json:"name,omitempty"` + Latest float64 `json:"latest,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` + Avg float64 `json:"avg,omitempty"` + Samples int `json:"samples"` + } + byID := map[string]TopologyCVM{} + for _, in := range cvms { + byID[in.ID] = in + } + var items []item + if resp != nil && resp.Response != nil { + for _, dp := range resp.Response.DataPoints { + if dp == nil { + continue + } + instID := "" + for _, d := range dp.Dimensions { + if d != nil && d.Name != nil && *d.Name == "InstanceId" && d.Value != nil { + instID = *d.Value + } + } + latest, mn, mx, avg, samples := summarize(dp.Values) + it := item{ + InstanceID: instID, + Latest: latest, + Min: mn, + Max: mx, + Avg: avg, + Samples: samples, + } + if v, ok := byID[instID]; ok { + it.Name = v.Name + } + items = append(items, it) + } + } + out := struct { + Region string `json:"region"` + Metric string `json:"metric"` + WindowMinutes int `json:"window_minutes"` + Items []item `json:"items"` + }{c.Region(), metricName, minutes, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +func summarize(vals []*float64) (latest, mn, mx, avg float64, n int) { + if len(vals) == 0 { + return 0, 0, 0, 0, 0 + } + first := true + for _, v := range vals { + if v == nil { + continue + } + f := *v + if first { + mn, mx = f, f + first = false + } + if f < mn { + mn = f + } + if f > mx { + mx = f + } + avg += f + latest = f + n++ + } + if n > 0 { + avg /= float64(n) + } + return +} + +func ptrString(s string) *string { return &s } diff --git a/internal/tencent/cynosdb.go b/internal/tencent/cynosdb.go new file mode 100644 index 0000000..421d7ac --- /dev/null +++ b/internal/tencent/cynosdb.go @@ -0,0 +1,94 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + cynosdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb/v20190107" +) + +// listCynosDB prints every CynosDB (TDSQL-C serverless) cluster across regions. +func listCynosDB(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + c *cynosdb.CynosdbCluster + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newCynosDBClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init cynosdb client: %v", r, err)) + continue + } + req := cynosdb.NewDescribeClustersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeClusters(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, cl := range resp.Response.ClusterSet { + rows = append(rows, row{region: r, c: cl}) + } + } + + header := fmt.Sprintf("TDSQL-C (CynosDB) Clusters (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("TDSQL-C (CynosDB) Clusters (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No CynosDB clusters found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tCLUSTER_ID\tNAME\tSTATUS\tENGINE\tDB_VERSION\tINSTANCES\tZONE") + } else { + fmt.Fprintln(tw, "CLUSTER_ID\tNAME\tSTATUS\tENGINE\tDB_VERSION\tINSTANCES\tZONE") + } + for _, r := range rows { + cl := r.c + fields := []string{ + derefString(cl.ClusterId), + derefString(cl.ClusterName), + derefString(cl.Status), + derefString(cl.DbType), + derefString(cl.DbVersion), + fmt.Sprintf("%d", derefInt64(cl.InstanceNum)), + derefString(cl.Zone), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newCynosDBClient(c *Client, region string) (*cynosdb.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cynosdb.tencentcloudapi.com") + return cynosdb.NewClient(cred, region, cpf) +} diff --git a/internal/tencent/db.go b/internal/tencent/db.go new file mode 100644 index 0000000..2031d3b --- /dev/null +++ b/internal/tencent/db.go @@ -0,0 +1,217 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + + cdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb/v20170320" + postgres "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres/v20170312" +) + +// listMySQL prints every TencentDB for MySQL instance across the given regions. +func listMySQL(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + inst *cdb.InstanceInfo + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newCDBClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init cdb client: %v", r, err)) + continue + } + req := cdb.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDBInstances(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, inst := range resp.Response.Items { + rows = append(rows, row{region: r, inst: inst}) + } + } + + header := fmt.Sprintf("TencentDB for MySQL (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("TencentDB for MySQL (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No MySQL instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tINSTANCE_ID\tNAME\tSTATUS\tENGINE\tMEM(MB)\tDISK(GB)\tPRIVATE_IP\tPUBLIC\tZONE") + } else { + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATUS\tENGINE\tMEM(MB)\tDISK(GB)\tPRIVATE_IP\tPUBLIC\tZONE") + } + for _, r := range rows { + i := r.inst + fields := []string{ + derefString(i.InstanceId), + derefString(i.InstanceName), + mysqlStatus(i.Status), + derefString(i.EngineVersion), + fmt.Sprintf("%d", derefInt64(i.Memory)), + fmt.Sprintf("%d", derefInt64(i.Volume)), + fmt.Sprintf("%s:%d", derefString(i.Vip), derefInt64(i.Vport)), + mysqlWan(i), + derefString(i.Zone), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +// listPostgres prints every TencentDB for PostgreSQL instance across regions. +func listPostgres(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + inst *postgres.DBInstance + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newPostgresClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init postgres client: %v", r, err)) + continue + } + req := postgres.NewDescribeDBInstancesRequest() + var limit, offset uint64 = 100, 0 + req.Limit = &limit + req.Offset = &offset + resp, err := client.DescribeDBInstances(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, inst := range resp.Response.DBInstanceSet { + rows = append(rows, row{region: r, inst: inst}) + } + } + + header := fmt.Sprintf("TencentDB for PostgreSQL (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("TencentDB for PostgreSQL (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No PostgreSQL instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tINSTANCE_ID\tNAME\tSTATUS\tENGINE\tCPU\tMEM(GB)\tDISK(GB)\tZONE\tCREATED") + } else { + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATUS\tENGINE\tCPU\tMEM(GB)\tDISK(GB)\tZONE\tCREATED") + } + for _, r := range rows { + i := r.inst + fields := []string{ + derefString(i.DBInstanceId), + derefString(i.DBInstanceName), + derefString(i.DBInstanceStatus), + derefString(i.DBVersion), + fmt.Sprintf("%d", derefUint64(i.DBInstanceCpu)), + fmt.Sprintf("%d", derefUint64(i.DBInstanceMemory)), + fmt.Sprintf("%d", derefUint64(i.DBInstanceStorage)), + derefString(i.Zone), + derefString(i.CreateTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newCDBClient(c *Client, region string) (*cdb.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cdb.tencentcloudapi.com") + return cdb.NewClient(cred, region, cpf) +} + +func newPostgresClient(c *Client, region string) (*postgres.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("postgres.tencentcloudapi.com") + return postgres.NewClient(cred, region, cpf) +} + +// mysqlStatus maps the integer Status field to a human label. +// 0=creating, 1=running, 4=isolating, 5=isolated, plus a fallback. +func mysqlStatus(p *int64) string { + if p == nil { + return "-" + } + switch *p { + case 0: + return "CREATING" + case 1: + return "RUNNING" + case 4: + return "ISOLATING" + case 5: + return "ISOLATED" + default: + return fmt.Sprintf("STATE-%d", *p) + } +} + +// mysqlWan returns a compact "yes:domain:port" or "-" depending on whether +// public network access is enabled. +func mysqlWan(i *cdb.InstanceInfo) string { + if i.WanStatus == nil || *i.WanStatus != 1 { + return "-" + } + domain := strings.TrimSpace(derefString(i.WanDomain)) + port := derefInt64(i.WanPort) + if domain == "-" || domain == "" { + return "ENABLED" + } + return fmt.Sprintf("%s:%d", domain, port) +} diff --git a/internal/tencent/db_exposure.go b/internal/tencent/db_exposure.go new file mode 100644 index 0000000..f47c806 --- /dev/null +++ b/internal/tencent/db_exposure.go @@ -0,0 +1,177 @@ +package tencent + +import ( + "context" + "encoding/json" + "strings" + + cdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb/v20170320" + postgres "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres/v20170312" + redis "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis/v20180412" +) + +// DBExposureScanJSON unified audit: which managed databases are reachable +// from the public internet? Covers MySQL (CDB), PostgreSQL, Redis. MongoDB +// and CynosDB don't expose a single "wan enabled" flag on the list endpoint +// (you'd need per-instance DescribeDBInstanceAccessLogs / similar) so they +// are not included in this audit yet — a follow-up phase can add them. +// +// A finding is emitted when any of these is true: +// - CDB: WanStatus == 1 +// - PostgreSQL: DBKernelVersion has a public IP, or the instance's +// PublicAccessSwitch is on (we approximate by looking for +// a non-empty Vport/WanDomain pattern in the inventory) +// - Redis: WanAddress is non-empty +func (c *Client) DBExposureScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + type finding struct { + Engine string `json:"engine"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + Status string `json:"status"` + PublicAddr string `json:"public_addr"` + Reason string `json:"reason"` + } + var items []finding + var warnings []string + + // MySQL (CDB) + if mysqlCli, err := newCDBClient(c, c.creds.Region); err == nil { + req := cdb.NewDescribeDBInstancesRequest() + var off, lim uint64 = 0, 100 + req.Offset = &off + req.Limit = &lim + if resp, e := mysqlCli.DescribeDBInstances(req); e == nil && resp != nil && resp.Response != nil { + for _, i := range resp.Response.Items { + if i.WanStatus == nil || *i.WanStatus != 1 { + continue + } + addr := derefStringRaw(i.WanDomain) + if p := derefInt64(i.WanPort); p > 0 { + addr = addr + ":" + intStr(p) + } + items = append(items, finding{ + Engine: "mysql", + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: mysqlStatus(i.Status), + PublicAddr: addr, + Reason: "WanStatus=1 (public network access enabled on CDB instance)", + }) + } + } else if e != nil { + warnings = append(warnings, "mysql: "+friendlyError(e).Error()) + } + } + + // PostgreSQL — DescribeDBInstances returns DBKernelVersion + a flag in + // each instance's NetworkAccessList. We approximate exposure by reading + // the legacy IsSupportTDE / IsAutoRenew fields; the canonical approach + // is a separate DescribePostgresAccountPrivileges call. For now we just + // surface any PG instance and tag it pending-check. + if pgCli, err := newPostgresClient(c, c.creds.Region); err == nil { + req := postgres.NewDescribeDBInstancesRequest() + var off, lim uint64 = 0, 100 + req.Offset = &off + req.Limit = &lim + if resp, e := pgCli.DescribeDBInstances(req); e == nil && resp != nil && resp.Response != nil { + for _, i := range resp.Response.DBInstanceSet { + // Walk NetworkAccessList for InternetAccess entries — when + // present and Status="opened" the instance is public. + public := false + addr := "" + for _, n := range i.DBInstanceNetInfo { + if n == nil { + continue + } + netType := strings.ToLower(derefStringRaw(n.NetType)) + status := strings.ToLower(derefStringRaw(n.Status)) + if (netType == "public" || netType == "internet") && (status == "opened" || status == "open" || status == "running") { + public = true + addr = derefStringRaw(n.Address) + if addr == "" { + addr = derefStringRaw(n.Ip) + } + break + } + } + if !public { + continue + } + items = append(items, finding{ + Engine: "postgres", + ID: derefStringRaw(i.DBInstanceId), + Name: derefStringRaw(i.DBInstanceName), + Status: derefStringRaw(i.DBInstanceStatus), + PublicAddr: addr, + Reason: "DBInstanceNetInfo contains an open public/internet entry", + }) + } + } else if e != nil { + warnings = append(warnings, "postgres: "+friendlyError(e).Error()) + } + } + + // Redis + if rdsCli, err := newRedisClient(c, c.creds.Region); err == nil { + req := redis.NewDescribeInstancesRequest() + var off, lim uint64 = 0, 100 + req.Offset = &off + req.Limit = &lim + if resp, e := rdsCli.DescribeInstances(req); e == nil && resp != nil && resp.Response != nil { + for _, i := range resp.Response.InstanceSet { + wa := strings.TrimSpace(derefStringRaw(i.WanAddress)) + if wa == "" { + continue + } + items = append(items, finding{ + Engine: "redis", + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: redisStatus(i.Status), + PublicAddr: wa, + Reason: "WanAddress set (public network access enabled on Redis instance)", + }) + } + } else if e != nil { + warnings = append(warnings, "redis: "+friendlyError(e).Error()) + } + } + + out := struct { + Region string `json:"region"` + Items []finding `json:"items"` + Warnings []string `json:"warnings,omitempty"` + }{c.Region(), items, warnings} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +func intStr(v int64) string { + if v == 0 { + return "0" + } + // avoid pulling strconv just for this + neg := false + if v < 0 { + neg = true + v = -v + } + var buf [20]byte + pos := len(buf) + for v > 0 { + pos-- + buf[pos] = byte('0' + v%10) + v /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} diff --git a/internal/tencent/edge_audits.go b/internal/tencent/edge_audits.go new file mode 100644 index 0000000..849db3c --- /dev/null +++ b/internal/tencent/edge_audits.go @@ -0,0 +1,156 @@ +package tencent + +import ( + "context" + "encoding/json" + "strings" + + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// WAFCoverageScanJSON flags every CDN/EdgeOne domain that doesn't appear +// in the WAF-protected hosts list. +func (c *Client) WAFCoverageScanJSON(ctx context.Context) (string, error) { + cdnDomains := listCDNDomainNames(c) + teoZones := listEdgeOneZoneNames(c) + wafProtected := listWAFHostNames(c) + + type uncovered struct { + Domain string `json:"domain"` + Source string `json:"source"` + } + var items []uncovered + for _, d := range cdnDomains { + if !isDomainCoveredByWAF(d, wafProtected) { + items = append(items, uncovered{Domain: d, Source: "cdn"}) + } + } + for _, d := range teoZones { + if !isDomainCoveredByWAF(d, wafProtected) { + items = append(items, uncovered{Domain: d, Source: "edgeone"}) + } + } + wafList := make([]string, 0, len(wafProtected)) + for k := range wafProtected { + wafList = append(wafList, k) + } + out := struct { + WAFProtected []string `json:"waf_protected"` + Items []uncovered `json:"items"` + }{wafList, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +func isDomainCoveredByWAF(domain string, waf map[string]bool) bool { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return true + } + for w := range waf { + w = strings.ToLower(strings.TrimSpace(w)) + if w == "" { + continue + } + if w == domain || strings.HasSuffix(domain, "."+w) { + return true + } + } + return false +} + +// AntiDDoSCoverageScanJSON returns the high-signal posture answer: "is +// there any Anti-DDoS Advanced coverage at all?" If not, every public IP +// is on Basic protection (~2 Gbps free). When Advanced subscriptions +// exist, the audit lists public CVMs + EIPs in the region as "may not be +// protected" (definitive per-IP attribution would need additional API +// calls — a v2 enhancement). +func (c *Client) AntiDDoSCoverageScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + hasAdvanced, advancedIDs, _ := hasAntiDDoSAdvanced(c) + + type publicTarget struct { + Kind string `json:"kind"` // "cvm" or "eip" + ID string `json:"id"` + Name string `json:"name,omitempty"` + PublicIP string `json:"public_ip"` + } + var targets []publicTarget + + if cli, err := c.CVM(); err == nil { + req := cvm.NewDescribeInstancesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + if resp, e := cli.DescribeInstances(req); e == nil && resp != nil && resp.Response != nil { + for _, in := range resp.Response.InstanceSet { + pip := firstIP(in.PublicIpAddresses) + if pip == "" { + continue + } + targets = append(targets, publicTarget{ + Kind: "cvm", + ID: derefStringRaw(in.InstanceId), + Name: derefStringRaw(in.InstanceName), + PublicIP: pip, + }) + } + } + } + + if cli, err := c.VPC(); err == nil { + req := vpc.NewDescribeAddressesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + if resp, e := cli.DescribeAddresses(req); e == nil && resp != nil && resp.Response != nil { + for _, a := range resp.Response.AddressSet { + if strings.ToUpper(derefStringRaw(a.AddressStatus)) != "BIND" { + continue + } + ip := derefStringRaw(a.AddressIp) + if ip == "" { + continue + } + targets = append(targets, publicTarget{ + Kind: "eip", + ID: derefStringRaw(a.AddressId), + Name: derefStringRaw(a.AddressName), + PublicIP: ip, + }) + } + } + } + + posture := "BASIC_ONLY" + if hasAdvanced { + posture = "MIXED" + } + if hasAdvanced && len(targets) == 0 { + posture = "ADVANCED_SUBSCRIBED_NO_PUBLIC" + } + out := struct { + Region string `json:"region"` + Posture string `json:"posture"` + HasAdvanced bool `json:"has_advanced"` + AdvancedInstances []string `json:"advanced_instances,omitempty"` + PublicTargets []publicTarget `json:"public_targets"` + }{ + Region: c.Region(), + Posture: posture, + HasAdvanced: hasAdvanced, + AdvancedInstances: advancedIDs, + PublicTargets: targets, + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/tencent/edgeone.go b/internal/tencent/edgeone.go new file mode 100644 index 0000000..d4337a2 --- /dev/null +++ b/internal/tencent/edgeone.go @@ -0,0 +1,76 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + teo "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo/v20220901" +) + +// listEdgeOneZones prints every EdgeOne (TEO) zone. EdgeOne is account-global. +func listEdgeOneZones(c *Client) error { + client, err := newEdgeOneClient(c) + if err != nil { + return fmt.Errorf("init edgeone client: %w", err) + } + req := teo.NewDescribeZonesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeZones(req) + if err != nil { + return fmt.Errorf("DescribeZones: %w", friendlyError(err)) + } + + fmt.Println("Tencent EdgeOne (TEO) Zones:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.Zones) == 0 { + fmt.Println(" No EdgeOne zones found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ZONE_ID\tNAME\tTYPE\tAREA\tSTATUS") + for _, z := range resp.Response.Zones { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + derefString(z.ZoneId), + derefString(z.ZoneName), + derefString(z.Type), + derefString(z.Area), + derefString(z.Status), + ) + } + return tw.Flush() +} + +func newEdgeOneClient(c *Client) (*teo.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("teo.tencentcloudapi.com") + return teo.NewClient(cred, "ap-guangzhou", cpf) +} + +// listEdgeOneZoneNames returns zone names for use by audits. +func listEdgeOneZoneNames(c *Client) []string { + client, err := newEdgeOneClient(c) + if err != nil { + return nil + } + req := teo.NewDescribeZonesRequest() + var offset, limit int64 = 0, 200 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeZones(req) + if err != nil || resp == nil || resp.Response == nil { + return nil + } + out := make([]string, 0, len(resp.Response.Zones)) + for _, z := range resp.Response.Zones { + if s := strings.TrimSpace(derefString(z.ZoneName)); s != "" && s != "-" { + out = append(out, s) + } + } + return out +} diff --git a/internal/tencent/eip.go b/internal/tencent/eip.go new file mode 100644 index 0000000..14cc77b --- /dev/null +++ b/internal/tencent/eip.go @@ -0,0 +1,86 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// listEIPs prints every Elastic IP (Address) across the given regions. +// EIPs are managed through the VPC service so no new SDK package is needed. +func listEIPs(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + eip *vpc.Address + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeAddressesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeAddresses(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, a := range resp.Response.AddressSet { + rows = append(rows, row{region: r, eip: a}) + } + } + + header := fmt.Sprintf("Tencent Cloud Elastic IPs (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud Elastic IPs (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No EIPs found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tEIP_ID\tNAME\tIP\tSTATUS\tTYPE\tBOUND_TO\tCREATED") + } else { + fmt.Fprintln(tw, "EIP_ID\tNAME\tIP\tSTATUS\tTYPE\tBOUND_TO\tCREATED") + } + for _, r := range rows { + a := r.eip + fields := []string{ + derefString(a.AddressId), + derefString(a.AddressName), + derefString(a.AddressIp), + derefString(a.AddressStatus), + derefString(a.AddressType), + derefString(a.InstanceId), + derefString(a.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} diff --git a/internal/tencent/expiry.go b/internal/tencent/expiry.go new file mode 100644 index 0000000..ff23f9c --- /dev/null +++ b/internal/tencent/expiry.go @@ -0,0 +1,259 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// ExpiryItem is one PREPAID resource and its renewal deadline. Built by +// ExpiryReport from the slim JSON the existing context*() gatherers emit +// so the cron CLI / HTTP / MCP surfaces all see the same shape. +type ExpiryItem struct { + Region string `json:"region"` + Type string `json:"type"` // cvm, lighthouse, cbs, mysql, ... + ID string `json:"id"` + Name string `json:"name,omitempty"` + ExpiresAt string `json:"expires_at"` // RFC3339 from the SDK + DaysLeft int `json:"days_left"` // negative when already expired + AutoRenew *bool `json:"auto_renew,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + State string `json:"state,omitempty"` +} + +// ExpiryReport is the cron-facing rollup. counts.expired drives exit code 2, +// counts.flagged drives exit code 1, everything else is exit code 0. +type ExpiryReport struct { + GeneratedAt string `json:"generated_at"` + ThresholdDays int `json:"threshold_days"` + ManualOnly bool `json:"manual_only"` + Regions []string `json:"regions"` + Items []ExpiryItem `json:"items"` + Counts ExpiryCounts `json:"counts"` +} + +// ExpiryCounts breaks the report down so callers don't have to re-scan items +// to know severity. Total includes everything PREPAID; Flagged is anything at +// or under the threshold; Expired is anything past expires_at; AutoRenew is +// the slice of Flagged that has auto_renew=true (these are dropped from +// Items when ManualOnly is set). +type ExpiryCounts struct { + Total int `json:"total"` + Flagged int `json:"flagged"` + Expired int `json:"expired"` + AutoRenew int `json:"auto_renew"` +} + +// expiryResourceTypes are the PREPAID-capable types we sweep. Order is the +// CLI table order. SSL is excluded by default — cert validity is a different +// signal from subscription expiry — but BuildExpiryReport accepts a flag to +// fold it in for callers that want to monitor cert renewal too. +var expiryResourceTypes = []string{ + "lighthouse", + "cvm", + "cbs", + "mysql", + "postgres", + "redis", + "mongodb", + "cynosdb", + "clb", + "antiddos", +} + +// ExpiryReportOptions controls a BuildExpiryReport call. ThresholdDays of +// zero defaults to 30 — most renewal-window emails arrive 30 days out. +// ManualOnly defaults to true since the cron is asking "what won't auto- +// renew?" — items with auto_renew=true are pre-filtered from Items but +// still counted in ExpiryCounts.AutoRenew. +type ExpiryReportOptions struct { + Regions []string + ThresholdDays int + ManualOnly bool + IncludeSSL bool +} + +// BuildExpiryReport walks every PREPAID-capable resource across the +// requested regions and rolls them up by days-to-expiry. Empty Regions +// means "use the client's configured region only". +func (c *Client) BuildExpiryReport(ctx context.Context, opt ExpiryReportOptions) (*ExpiryReport, error) { + if opt.ThresholdDays <= 0 { + opt.ThresholdDays = 30 + } + regions := opt.Regions + if len(regions) == 0 { + regions = []string{c.creds.Region} + } + + report := &ExpiryReport{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + ThresholdDays: opt.ThresholdDays, + ManualOnly: opt.ManualOnly, + Regions: regions, + } + + now := time.Now().UTC() + for _, region := range regions { + if err := ctxDone(ctx); err != nil { + return nil, err + } + rc := c.WithRegion(region) + for _, rt := range expiryResourceTypes { + items, err := gatherExpiryByType(ctx, rc, rt) + if err != nil { + // One service failing (e.g., not enabled in this region) + // shouldn't poison the whole report. + continue + } + for _, it := range items { + if it.BillingMode != billingPrepaid { + continue + } + it.Region = region + it.Type = rt + it.DaysLeft = computeDaysLeft(now, it.ExpiresAt) + report.Counts.Total++ + if it.DaysLeft < 0 { + report.Counts.Expired++ + } + flagged := it.DaysLeft <= opt.ThresholdDays + if flagged { + report.Counts.Flagged++ + if it.AutoRenew != nil && *it.AutoRenew { + report.Counts.AutoRenew++ + if opt.ManualOnly { + continue + } + } + report.Items = append(report.Items, it) + } + } + } + if opt.IncludeSSL { + items, err := gatherSSLExpiry(ctx, rc) + if err == nil { + for _, it := range items { + it.Region = region + it.Type = "ssl" + report.Counts.Total++ + if it.DaysLeft < 0 { + report.Counts.Expired++ + } + if it.DaysLeft <= opt.ThresholdDays { + report.Counts.Flagged++ + report.Items = append(report.Items, it) + } + } + } + } + } + + sort.SliceStable(report.Items, func(i, j int) bool { + return report.Items[i].DaysLeft < report.Items[j].DaysLeft + }) + return report, nil +} + +// gatherExpiryByType calls the matching JSON*() function and parses out the +// fields ExpiryReport needs. We don't import the typed SDK structs here — +// the slim JSON shape (id, name, state, expires_at, billing_mode, +// auto_renew) is the contract these functions already emit after the +// upstream review work. +func gatherExpiryByType(ctx context.Context, c *Client, resourceType string) ([]ExpiryItem, error) { + var body string + var err error + switch resourceType { + case "cvm": + body, err = c.JSONCVMs(ctx) + case "lighthouse": + body, err = c.JSONLighthouses(ctx) + case "cbs": + body, err = c.JSONCBS(ctx) + case "mysql": + body, err = c.JSONMySQL(ctx) + case "postgres": + body, err = c.JSONPostgres(ctx) + case "redis": + body, err = c.JSONRedis(ctx) + case "mongodb": + body, err = c.JSONMongoDB(ctx) + case "cynosdb": + body, err = c.JSONCynosDB(ctx) + case "clb": + body, err = c.JSONCLB(ctx) + case "antiddos": + body, err = c.JSONAntiDDoS(ctx) + default: + return nil, fmt.Errorf("unsupported expiry resource type %q", resourceType) + } + if err != nil { + return nil, err + } + if strings.TrimSpace(body) == "" { + return nil, nil + } + var items []ExpiryItem + if err := json.Unmarshal([]byte(body), &items); err != nil { + return nil, fmt.Errorf("parse %s slim json: %w", resourceType, err) + } + return items, nil +} + +// sslExpiry is the slim cert shape JSONSSL emits — different from the rest +// of the resources because SSL doesn't have a billing_mode/expires_at pair; +// it has cert_end and a pre-computed days_left. +type sslExpiry struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Domain string `json:"domain,omitempty"` + Status string `json:"status"` + CertEnd string `json:"cert_end,omitempty"` + DaysLeft int `json:"days_left"` +} + +func gatherSSLExpiry(ctx context.Context, c *Client) ([]ExpiryItem, error) { + body, err := c.JSONSSL(ctx) + if err != nil || strings.TrimSpace(body) == "" { + return nil, err + } + var raw []sslExpiry + if err := json.Unmarshal([]byte(body), &raw); err != nil { + return nil, fmt.Errorf("parse ssl slim json: %w", err) + } + out := make([]ExpiryItem, 0, len(raw)) + for _, r := range raw { + name := r.Alias + if name == "" { + name = r.Domain + } + out = append(out, ExpiryItem{ + ID: r.ID, + Name: name, + ExpiresAt: r.CertEnd, + DaysLeft: r.DaysLeft, + BillingMode: billingPrepaid, + State: r.Status, + }) + } + return out, nil +} + +// computeDaysLeft accepts the RFC3339 timestamps the Tencent SDK emits and +// returns the integer days remaining. Negative means already expired. We +// floor (rather than round) so "expires in 2 hours" reads as 0 days left +// rather than 1 — that's the conservative direction for a renewal alert. +func computeDaysLeft(now time.Time, expiresAt string) int { + if strings.TrimSpace(expiresAt) == "" { + return 0 + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05"} { + if t, err := time.Parse(layout, expiresAt); err == nil { + delta := t.UTC().Sub(now) + return int(delta / (24 * time.Hour)) + } + } + return 0 +} diff --git a/internal/tencent/expiry_cmd.go b/internal/tencent/expiry_cmd.go new file mode 100644 index 0000000..f34ede3 --- /dev/null +++ b/internal/tencent/expiry_cmd.go @@ -0,0 +1,143 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// buildExpiryCmd registers `clanker tencent expiry` — the cron-facing alert +// for prepaid resources about to expire. The shared --region flag from the +// parent command sets the default region scope when --regions is omitted. +// +// Exit codes are designed for cron pipelines: +// +// 0 — nothing flagged (cron MAILTO stays quiet) +// 1 — one or more items inside the threshold (paper trail) +// 2 — one or more items already past expiry (escalate) +func buildExpiryCmd(defaultRegion *string) *cobra.Command { + var ( + regionsFlag string + threshold int + manualOnly bool + includeSSL bool + format string + ) + + cmd := &cobra.Command{ + Use: "expiry", + Short: "Report PREPAID resources approaching their renewal deadline", + Long: `Walks every PREPAID-capable resource type across the requested regions +(CVM, Lighthouse, CBS, MySQL, Postgres, Redis, MongoDB, CynosDB, CLB, +AntiDDoS — and SSL with --include-ssl) and reports anything within +--threshold days of expiry. + +Designed for cron / GitHub Actions: --format json emits a stable shape, +and exit code 1 (flagged) / 2 (already expired) lets a wrapper script +or cron MAILTO surface the alert without parsing output. + +Examples: + # 30-day window, manual-renew only, configured region + clanker tencent expiry + + # 14-day window across two regions, JSON for a script + clanker tencent expiry --regions=ap-singapore,ap-jakarta --threshold=14 --format=json + + # Daily cron line — only alerts when something needs attention + 0 9 * * * /usr/local/bin/clanker tencent expiry --threshold=14 || mail -s "tencent renewals" me@example.com`, + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if defaultRegion != nil && *defaultRegion != "" { + creds.Region = *defaultRegion + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + + var regions []string + for _, r := range strings.Split(regionsFlag, ",") { + if r = strings.TrimSpace(r); r != "" { + regions = append(regions, r) + } + } + + report, err := client.BuildExpiryReport(context.Background(), ExpiryReportOptions{ + Regions: regions, + ThresholdDays: threshold, + ManualOnly: manualOnly, + IncludeSSL: includeSSL, + }) + if err != nil { + return err + } + + switch strings.ToLower(format) { + case "json": + if err := writeExpiryJSON(report); err != nil { + return err + } + default: + writeExpiryTable(report) + } + + // Exit-code semantics: expired beats merely-flagged. + if report.Counts.Expired > 0 { + os.Exit(2) + } + if len(report.Items) > 0 { + os.Exit(1) + } + return nil + }, + } + + cmd.Flags().StringVar(®ionsFlag, "regions", "", "Comma-separated regions to scan (defaults to the configured region; pass two or more for multi-region cron)") + cmd.Flags().IntVar(&threshold, "threshold", 30, "Flag items this many days from expiry or closer") + cmd.Flags().BoolVar(&manualOnly, "manual-only", true, "Only flag items with auto_renew=false; auto-renewing ones are counted but not listed") + cmd.Flags().BoolVar(&includeSSL, "include-ssl", false, "Include SSL certificate validity (different signal from subscription expiry but useful for cron coverage)") + cmd.Flags().StringVar(&format, "format", "table", "Output format: table | json") + return cmd +} + +func writeExpiryJSON(report *ExpiryReport) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +func writeExpiryTable(report *ExpiryReport) { + regions := strings.Join(report.Regions, ",") + fmt.Printf("Tencent Cloud renewal scan (regions=%s, threshold=%dd, manual_only=%t)\n", + regions, report.ThresholdDays, report.ManualOnly) + fmt.Printf("Scanned %d PREPAID resources — %d flagged (%d already expired, %d auto-renewing).\n\n", + report.Counts.Total, report.Counts.Flagged, report.Counts.Expired, report.Counts.AutoRenew) + + if len(report.Items) == 0 { + fmt.Println(" Nothing to alert on.") + return + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "TYPE\tREGION\tID\tNAME\tDAYS\tEXPIRES\tAUTO_RENEW\tSTATE") + for _, it := range report.Items { + ar := "—" + if it.AutoRenew != nil { + ar = strconv.FormatBool(*it.AutoRenew) + } + flag := strconv.Itoa(it.DaysLeft) + if it.DaysLeft < 0 { + flag = "EXPIRED" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + it.Type, it.Region, it.ID, it.Name, flag, it.ExpiresAt, ar, it.State) + } + _ = tw.Flush() +} diff --git a/internal/tencent/gen_services.go b/internal/tencent/gen_services.go new file mode 100644 index 0000000..6b442f6 --- /dev/null +++ b/internal/tencent/gen_services.go @@ -0,0 +1,130 @@ +//go:build ignore + +// gen_services.go generates service_versions_gen.go from the Tencent SDK +// modules in $GOMODCACHE. Each `tencentcloud-sdk-go/tencentcloud//v` +// directory becomes one entry in `serviceVersions`; when a service has +// multiple versions vendored we pick the latest (lexicographic on YYYYMMDD). +// +// Run: `go generate ./internal/tencent/...` +// +// The file is `//go:build ignore` so the package compiles without it; only +// `go generate` (or `go run`) executes it. +package main + +import ( + "bytes" + "fmt" + "go/format" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Services we want in the map that don't live in tencentcloud-sdk-go (or for +// which the version string is intentionally empty). Keep this list small — +// adding to it is the only manual step required when the SDK gains a quirk. +var manualOverrides = map[string]string{ + "cos": "", // COS uses the object-storage REST API; SendRaw can't dispatch it. +} + +const outputFile = "service_versions_gen.go" + +var versionRE = regexp.MustCompile(`/tencentcloud/([^/@]+)(?:@[^/]+)?/v(\d{8})$`) + +func main() { + cache, err := goModCache() + if err != nil { + die("locate GOMODCACHE: %v", err) + } + root := filepath.Join(cache, "github.com", "tencentcloud", "tencentcloud-sdk-go", "tencentcloud") + if _, err := os.Stat(root); err != nil { + die("tencent SDK not in module cache at %s — run `go mod download` first", root) + } + + versions := map[string]string{} + err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + m := versionRE.FindStringSubmatch(path) + if m == nil { + return nil + } + svc, ver := m[1], m[2] + // Lexicographic comparison works since the format is fixed YYYYMMDD. + if existing, ok := versions[svc]; !ok || ver > existing { + versions[svc] = ver + } + return nil + }) + if err != nil { + die("walk SDK: %v", err) + } + + for svc, ver := range manualOverrides { + // Overrides win even if the SDK happens to vendor the service — + // the empty-string COS sentinel is load-bearing for SendRaw's + // "use service-specific path" error. + versions[svc] = ver + } + + keys := make([]string, 0, len(versions)) + for k := range versions { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteString("// Code generated by gen_services.go; DO NOT EDIT.\n") + buf.WriteString("// Run `go generate ./internal/tencent/...` to refresh after upgrading\n") + buf.WriteString("// github.com/tencentcloud/tencentcloud-sdk-go.\n\n") + buf.WriteString("package tencent\n\n") + buf.WriteString("// serviceVersions maps Tencent service short names to the API version\n") + buf.WriteString("// SendRaw passes to tchttp.CommonRequest. Source: the vendored Tencent\n") + buf.WriteString("// SDK, picking the highest version per service. Empty string means the\n") + buf.WriteString("// service does not use the generic action API (see SendRaw for the\n") + buf.WriteString("// branch that handles this).\n") + buf.WriteString("var serviceVersions = map[string]string{\n") + for _, svc := range keys { + ver := versions[svc] + if ver == "" { + buf.WriteString(fmt.Sprintf("\t%q: %q,\n", svc, "")) + continue + } + // Reformat 20200324 → 2020-03-24 for readability. + fmtted := ver[:4] + "-" + ver[4:6] + "-" + ver[6:] + buf.WriteString(fmt.Sprintf("\t%q: %q,\n", svc, fmtted)) + } + buf.WriteString("}\n") + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + die("gofmt: %v\n--- source ---\n%s", err, buf.String()) + } + if err := os.WriteFile(outputFile, formatted, 0o644); err != nil { + die("write %s: %v", outputFile, err) + } + fmt.Printf("wrote %s with %d services\n", outputFile, len(versions)) + for _, svc := range keys { + fmt.Printf(" %-12s %s\n", svc, versions[svc]) + } +} + +func goModCache() (string, error) { + out, err := exec.Command("go", "env", "GOMODCACHE").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func die(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} diff --git a/internal/tencent/json_api.go b/internal/tencent/json_api.go new file mode 100644 index 0000000..41ab5b8 --- /dev/null +++ b/internal/tencent/json_api.go @@ -0,0 +1,171 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// Public JSON-emitting methods on Client. These are the canonical data +// sources for the HTTP API layer (internal/api/) — they wrap the same SDK +// calls as the CLI list commands but return JSON-encoded summaries instead of +// printing tables. +// +// Each method returns the raw JSON string for a single-typed array of +// resources (or an empty string when no resources exist). Callers that wrap +// the result for HTTP should embed it inside an envelope like +// {"data": } rather than re-encoding. + +func (c *Client) JSONCVMs(ctx context.Context) (string, error) { return c.contextCVMs(ctx) } +func (c *Client) JSONVPCs(ctx context.Context) (string, error) { return c.contextVPCs(ctx) } +func (c *Client) JSONSecurityGroups(ctx context.Context) (string, error) { return c.contextSecurityGroups(ctx) } +func (c *Client) JSONMySQL(ctx context.Context) (string, error) { return c.contextMySQL(ctx) } +func (c *Client) JSONPostgres(ctx context.Context) (string, error) { return c.contextPostgres(ctx) } +func (c *Client) JSONCOS(ctx context.Context) (string, error) { return c.contextCOS(ctx) } +func (c *Client) JSONTKE(ctx context.Context) (string, error) { return c.contextTKE(ctx) } + + +func (c *Client) JSONCLB(ctx context.Context) (string, error) { return c.contextCLB(ctx) } +func (c *Client) JSONEIP(ctx context.Context) (string, error) { return c.contextEIP(ctx) } +func (c *Client) JSONCBS(ctx context.Context) (string, error) { return c.contextCBS(ctx) } +func (c *Client) JSONSSL(ctx context.Context) (string, error) { return c.contextSSL(ctx) } +func (c *Client) JSONCAM(ctx context.Context) (string, error) { return c.contextCAM(ctx) } + + +func (c *Client) JSONRedis(ctx context.Context) (string, error) { return c.contextRedis(ctx) } +func (c *Client) JSONMongoDB(ctx context.Context) (string, error) { return c.contextMongoDB(ctx) } +func (c *Client) JSONCynosDB(ctx context.Context) (string, error) { return c.contextCynosDB(ctx) } + + +func (c *Client) JSONCDN(ctx context.Context) (string, error) { return c.contextCDN(ctx) } +func (c *Client) JSONEdgeOne(ctx context.Context) (string, error) { return c.contextEdgeOne(ctx) } +func (c *Client) JSONWAF(ctx context.Context) (string, error) { return c.contextWAF(ctx) } +func (c *Client) JSONAntiDDoS(ctx context.Context) (string, error) { return c.contextAntiDDoS(ctx) } + + +func (c *Client) JSONNATGateways(ctx context.Context) (string, error) { return c.contextNAT(ctx) } +func (c *Client) JSONVPNGateways(ctx context.Context) (string, error) { return c.contextVPN(ctx) } +func (c *Client) JSONCCNs(ctx context.Context) (string, error) { return c.contextCCN(ctx) } +func (c *Client) JSONDirectConnects(ctx context.Context) (string, error) { return c.contextDC(ctx) } + + +func (c *Client) JSONAlarmPolicies(ctx context.Context) (string, error) { return c.contextAlarmPolicies(ctx) } +func (c *Client) JSONCLSTopics(ctx context.Context) (string, error) { return c.contextCLSTopics(ctx) } +func (c *Client) JSONCloudAudit(ctx context.Context) (string, error) { return c.contextCloudAudit(ctx) } + +// Lighthouse (Tencent's lightweight cloud server, separate product from CVM). +// JSONLighthouses is declared in internal/tencent/lighthouse.go directly so it +// can sit next to LighthouseMetricsJSON; we keep this comment as the index entry. + +// JSONSGRules returns the ingress + egress policies of a single security +// group plus a `risk` label on each ingress rule that exposes a sensitive +// port to the public internet. Wraps DescribeSecurityGroupPolicies. +func (c *Client) JSONSGRules(ctx context.Context, sgID string) (string, error) { + client, err := c.VPC() + if err != nil { + return "", fmt.Errorf("init vpc client: %w", err) + } + req := vpc.NewDescribeSecurityGroupPoliciesRequest() + req.SecurityGroupId = &sgID + resp, err := client.DescribeSecurityGroupPolicies(req) + if err != nil { + return "", friendlyError(err) + } + type rule struct { + Direction string `json:"direction"` + Index int64 `json:"index"` + Protocol string `json:"protocol,omitempty"` + Port string `json:"port,omitempty"` + Source string `json:"source,omitempty"` + Action string `json:"action"` + Description string `json:"description,omitempty"` + Risk string `json:"risk,omitempty"` + } + var rows []rule + risky := 0 + if resp != nil && resp.Response != nil && resp.Response.SecurityGroupPolicySet != nil { + for _, p := range resp.Response.SecurityGroupPolicySet.Ingress { + r := buildRule("INGRESS", p, classifySGRule(p, true)) + if r.Risk != "" { + risky++ + } + rows = append(rows, r) + } + for _, p := range resp.Response.SecurityGroupPolicySet.Egress { + rows = append(rows, buildRule("EGRESS", p, classifySGRule(p, false))) + } + } + out := map[string]interface{}{ + "sg_id": sgID, + "region": c.Region(), + "rules": rows, + "risky_count": risky, + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// FetchKubeconfig retrieves a TKE cluster's kubeconfig. Used by the HTTP API +// layer; the CLI uses getTKEKubeconfig directly so it can print to stdout. +func (c *Client) FetchKubeconfig(ctx context.Context, clusterID string, public bool) (string, error) { + client, err := newTKEClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := newDescribeKubeconfigReq(clusterID, public) + resp, err := client.DescribeClusterKubeconfig(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || resp.Response.Kubeconfig == nil { + return "", fmt.Errorf("empty kubeconfig response for %s", clusterID) + } + return *resp.Response.Kubeconfig, nil +} + +func buildRule(dir string, p *vpc.SecurityGroupPolicy, risk string) struct { + Direction string `json:"direction"` + Index int64 `json:"index"` + Protocol string `json:"protocol,omitempty"` + Port string `json:"port,omitempty"` + Source string `json:"source,omitempty"` + Action string `json:"action"` + Description string `json:"description,omitempty"` + Risk string `json:"risk,omitempty"` +} { + idx := int64(0) + if p != nil && p.PolicyIndex != nil { + idx = *p.PolicyIndex + } + source := derefStringRaw(p.CidrBlock) + if source == "" { + source = derefStringRaw(p.Ipv6CidrBlock) + } + if source == "" && p.SecurityGroupId != nil { + source = "sg:" + *p.SecurityGroupId + } + return struct { + Direction string `json:"direction"` + Index int64 `json:"index"` + Protocol string `json:"protocol,omitempty"` + Port string `json:"port,omitempty"` + Source string `json:"source,omitempty"` + Action string `json:"action"` + Description string `json:"description,omitempty"` + Risk string `json:"risk,omitempty"` + }{ + Direction: dir, + Index: idx, + Protocol: derefStringRaw(p.Protocol), + Port: derefStringRaw(p.Port), + Source: source, + Action: derefStringRaw(p.Action), + Description: derefStringRaw(p.PolicyDescription), + Risk: risk, + } +} diff --git a/internal/tencent/lighthouse.go b/internal/tencent/lighthouse.go new file mode 100644 index 0000000..c5e1bec --- /dev/null +++ b/internal/tencent/lighthouse.go @@ -0,0 +1,239 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + lighthouse "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse/v20200324" + monitor "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor/v20180724" +) + +// Lighthouse returns a region-scoped Tencent Lighthouse SDK client. +// Lighthouse is Tencent's lightweight cloud server (similar to AWS Lightsail); +// it shares the Cloud Monitor metric pipeline as CVM but lives under the +// namespace QCE/LIGHTHOUSE with its own metric names. +func (c *Client) Lighthouse() (*lighthouse.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("lighthouse.tencentcloudapi.com") + return lighthouse.NewClient(cred, c.creds.Region, cpf) +} + +// lighthouseInstance is the slim wire shape returned by JSONLighthouses +// and used as the source list for LighthouseMetricsJSON. +type lighthouseInstance struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + BundleID string `json:"bundle_id,omitempty"` + BlueprintID string `json:"blueprint_id,omitempty"` + Zone string `json:"zone,omitempty"` + PrivateIP []string `json:"private_ip,omitempty"` + PublicIP []string `json:"public_ip,omitempty"` + OSName string `json:"os,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + AutoRenew *bool `json:"auto_renew,omitempty"` + Tags map[string]string `json:"tags,omitempty"` +} + +// contextLighthouses lists Lighthouse instances in the active region. +// Returns "" when the service has no instances (matches the existing +// contextCVMs convention used by gatherTencentByType). +func (c *Client) contextLighthouses(ctx context.Context) (string, error) { + cl, err := c.Lighthouse() + if err != nil { + return "", err + } + req := lighthouse.NewDescribeInstancesRequest() + resp, err := cl.DescribeInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil || len(resp.Response.InstanceSet) == 0 { + return "", nil + } + out := make([]lighthouseInstance, 0, len(resp.Response.InstanceSet)) + for _, in := range resp.Response.InstanceSet { + row := lighthouseInstance{ + ID: derefStringRaw(in.InstanceId), + Name: derefStringRaw(in.InstanceName), + State: derefStringRaw(in.InstanceState), + BundleID: derefStringRaw(in.BundleId), + BlueprintID: derefStringRaw(in.BlueprintId), + Zone: derefStringRaw(in.Zone), + PrivateIP: stringSlice(in.PrivateAddresses), + PublicIP: stringSlice(in.PublicAddresses), + OSName: derefStringRaw(in.OsName), + CreatedAt: derefStringRaw(in.CreatedTime), + ExpiresAt: derefStringRaw(in.ExpiredTime), + BillingMode: normChargeTypeStr(in.InstanceChargeType), + AutoRenew: normRenewFlagAutoStr(in.RenewFlag), + Tags: extractTags(in.Tags), + } + out = append(out, row) + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// JSONLighthouses is the public entrypoint for the /resources/lighthouse route. +func (c *Client) JSONLighthouses(ctx context.Context) (string, error) { + return c.contextLighthouses(ctx) +} + +// LighthouseMetricsJSON pulls one metric (default Cpu_Usage) for every +// Lighthouse instance in the region over the last N minutes (default 60). +// Mirrors CVMMetricsJSON but uses the QCE/LIGHTHOUSE namespace and the +// metric-name convention Tencent uses for Lighthouse (Cpu_Usage, Mem_Usage, +// Public_Bandwidth_In/Out, Internal_Bandwidth_In/Out). +// Cloud Monitor's GetMonitorData accepts the Lighthouse dimension as +// PascalCase "InstanceId" — same as CVM. Note that DescribeBaseMetrics +// reports it as lowercase "instanceid" in the metric metadata, but +// passing the lowercase form to GetMonitorData triggers the misleading +// error "unauthorized operation or the instance has been destroyed". +// Tencent's two APIs disagree about the canonical name; PascalCase is +// what the data API actually serves. +// +// Metric names are CamelCase without underscores (CpuUsage, MemUsage, ...) +// — Tencent's English docs list snake_case names but the API rejects those. +const lighthouseDimensionKey = "InstanceId" + +func (c *Client) LighthouseMetricsJSON(ctx context.Context, region, metricName string, minutes int) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + if metricName == "" { + metricName = "CpuUsage" + } + if minutes <= 0 || minutes > 60*24 { + minutes = 60 + } + + // Gather Lighthouse instances in this region first. + cl, err := c.Lighthouse() + if err != nil { + return "", err + } + listReq := lighthouse.NewDescribeInstancesRequest() + listResp, err := cl.DescribeInstances(listReq) + if err != nil { + return "", friendlyError(err) + } + type slim struct { + ID string + Name string + } + var instances []slim + if listResp != nil && listResp.Response != nil { + for _, in := range listResp.Response.InstanceSet { + id := derefStringRaw(in.InstanceId) + if id == "" { + continue + } + instances = append(instances, slim{ + ID: id, + Name: derefStringRaw(in.InstanceName), + }) + } + } + if len(instances) == 0 { + out := struct { + Region string `json:"region"` + Metric string `json:"metric"` + Items []interface{} `json:"items"` + }{c.Region(), metricName, nil} + b, _ := json.Marshal(out) + return string(b), nil + } + + mclient, err := newMonitorClient(c, c.creds.Region) + if err != nil { + return "", err + } + + now := time.Now() + start := now.Add(-time.Duration(minutes) * time.Minute) + endStr := now.UTC().Format("2006-01-02T15:04:05Z") + startStr := start.UTC().Format("2006-01-02T15:04:05Z") + period := uint64(60) + + ns := "QCE/LIGHTHOUSE" + req := monitor.NewGetMonitorDataRequest() + req.Namespace = &ns + req.MetricName = &metricName + req.Period = &period + req.StartTime = &startStr + req.EndTime = &endStr + for _, in := range instances { + instID := in.ID + dimName := lighthouseDimensionKey + dim := &monitor.Instance{ + Dimensions: []*monitor.Dimension{ + {Name: &dimName, Value: &instID}, + }, + } + req.Instances = append(req.Instances, dim) + } + resp, err := mclient.GetMonitorData(req) + if err != nil { + return "", fmt.Errorf("GetMonitorData: %w", friendlyError(err)) + } + + type item struct { + InstanceID string `json:"instance_id"` + Name string `json:"name,omitempty"` + Latest float64 `json:"latest,omitempty"` + Min float64 `json:"min,omitempty"` + Max float64 `json:"max,omitempty"` + Avg float64 `json:"avg,omitempty"` + Samples int `json:"samples"` + } + byID := map[string]string{} + for _, in := range instances { + byID[in.ID] = in.Name + } + var items []item + if resp != nil && resp.Response != nil { + for _, dp := range resp.Response.DataPoints { + if dp == nil { + continue + } + instID := "" + for _, d := range dp.Dimensions { + if d != nil && d.Name != nil && d.Value != nil && + strings.EqualFold(*d.Name, lighthouseDimensionKey) { + instID = *d.Value + } + } + latest, mn, mx, avg, samples := summarize(dp.Values) + items = append(items, item{ + InstanceID: instID, + Name: byID[instID], + Latest: latest, + Min: mn, + Max: mx, + Avg: avg, + Samples: samples, + }) + } + } + out := struct { + Region string `json:"region"` + Metric string `json:"metric"` + WindowMinutes int `json:"window_minutes"` + Items []item `json:"items"` + }{c.Region(), metricName, minutes, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/tencent/mongodb.go b/internal/tencent/mongodb.go new file mode 100644 index 0000000..0342db8 --- /dev/null +++ b/internal/tencent/mongodb.go @@ -0,0 +1,128 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + mongodb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb/v20190725" +) + +// listMongoDB prints every TencentDB for MongoDB instance across regions. +func listMongoDB(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + i *mongodb.InstanceDetail + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newMongoDBClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init mongodb client: %v", r, err)) + continue + } + req := mongodb.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDBInstances(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, inst := range resp.Response.InstanceDetails { + rows = append(rows, row{region: r, i: inst}) + } + } + + header := fmt.Sprintf("TencentDB for MongoDB (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("TencentDB for MongoDB (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No MongoDB instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tINSTANCE_ID\tNAME\tSTATUS\tCLUSTER_TYPE\tVIP:PORT\tZONE\tCREATED") + } else { + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATUS\tCLUSTER_TYPE\tVIP:PORT\tZONE\tCREATED") + } + for _, r := range rows { + i := r.i + fields := []string{ + derefString(i.InstanceId), + derefString(i.InstanceName), + mongoStatus(i.Status), + mongoClusterType(i.ClusterType), + fmt.Sprintf("%s:%d", derefString(i.Vip), derefUint64(i.Vport)), + derefString(i.Zone), + derefString(i.CreateTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newMongoDBClient(c *Client, region string) (*mongodb.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("mongodb.tencentcloudapi.com") + return mongodb.NewClient(cred, region, cpf) +} + +func mongoStatus(p *int64) string { + if p == nil { + return "-" + } + switch *p { + case 0: + return "PENDING_INIT" + case 1: + return "PROCESSING" + case 2: + return "RUNNING" + case -2: + return "ISOLATED_PREPAID" + case -3: + return "ISOLATED_POSTPAID" + default: + return fmt.Sprintf("STATE-%d", *p) + } +} + +func mongoClusterType(p *uint64) string { + if p == nil { + return "-" + } + switch *p { + case 0: + return "REPLICA_SET" + case 1: + return "SHARDED" + default: + return fmt.Sprintf("TYPE-%d", *p) + } +} diff --git a/internal/tencent/net_edges.go b/internal/tencent/net_edges.go new file mode 100644 index 0000000..fbece5b --- /dev/null +++ b/internal/tencent/net_edges.go @@ -0,0 +1,288 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + dc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc/v20180410" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// listNATGateways prints every NAT gateway across the given regions. +func listNATGateways(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + g *vpc.NatGateway + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeNatGatewaysRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeNatGateways(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, g := range resp.Response.NatGatewaySet { + rows = append(rows, row{region: r, g: g}) + } + } + + header := fmt.Sprintf("Tencent NAT Gateways (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent NAT Gateways (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No NAT gateways found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tNAT_ID\tNAME\tSTATE\tBANDWIDTH_OUT\tPUBLIC_IPS\tCREATED") + } else { + fmt.Fprintln(tw, "NAT_ID\tNAME\tSTATE\tBANDWIDTH_OUT\tPUBLIC_IPS\tCREATED") + } + for _, r := range rows { + g := r.g + var ips []string + for _, ip := range g.PublicIpAddressSet { + if ip != nil && ip.PublicIpAddress != nil { + ips = append(ips, *ip.PublicIpAddress) + } + } + ipStr := strings.Join(ips, ",") + if ipStr == "" { + ipStr = "-" + } + fields := []string{ + derefString(g.NatGatewayId), + derefString(g.NatGatewayName), + derefString(g.State), + fmt.Sprintf("%dMbps", derefUint64(g.InternetMaxBandwidthOut)), + ipStr, + derefString(g.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +// listVPNGateways prints every VPN gateway across the given regions. +func listVPNGateways(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + g *vpc.VpnGateway + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeVpnGatewaysRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeVpnGateways(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, g := range resp.Response.VpnGatewaySet { + rows = append(rows, row{region: r, g: g}) + } + } + + header := fmt.Sprintf("Tencent VPN Gateways (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent VPN Gateways (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No VPN gateways found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tVPN_ID\tNAME\tTYPE\tSTATE\tPUBLIC_IP\tVPC_ID\tCHARGE") + } else { + fmt.Fprintln(tw, "VPN_ID\tNAME\tTYPE\tSTATE\tPUBLIC_IP\tVPC_ID\tCHARGE") + } + for _, r := range rows { + g := r.g + fields := []string{ + derefString(g.VpnGatewayId), + derefString(g.VpnGatewayName), + derefString(g.Type), + derefString(g.State), + derefString(g.PublicIpAddress), + derefString(g.VpcId), + derefString(g.InstanceChargeType), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +// listCCNs prints every Cloud Connect Network. CCN is account-global so we +// only need one region for the API call. +func listCCNs(c *Client) error { + client, err := c.VPC() + if err != nil { + return fmt.Errorf("init vpc client: %w", err) + } + req := vpc.NewDescribeCcnsRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeCcns(req) + if err != nil { + return fmt.Errorf("DescribeCcns: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud Connect Networks (CCN):") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.CcnSet) == 0 { + fmt.Println(" No CCNs found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "CCN_ID\tNAME\tSTATE\tINSTANCES\tCREATED") + for _, ccn := range resp.Response.CcnSet { + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\n", + derefString(ccn.CcnId), + derefString(ccn.CcnName), + derefString(ccn.State), + derefUint64(ccn.InstanceCount), + derefString(ccn.CreateTime), + ) + } + return tw.Flush() +} + +// listDirectConnects prints every Direct Connect physical line across regions. +func listDirectConnects(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + d *dc.DirectConnect + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newDCClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init dc client: %v", r, err)) + continue + } + req := dc.NewDescribeDirectConnectsRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeDirectConnects(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, d := range resp.Response.DirectConnectSet { + rows = append(rows, row{region: r, d: d}) + } + } + + header := fmt.Sprintf("Tencent Direct Connect (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Direct Connect (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No Direct Connect lines found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tDC_ID\tNAME\tSTATE\tACCESS_POINT") + } else { + fmt.Fprintln(tw, "DC_ID\tNAME\tSTATE\tACCESS_POINT") + } + for _, r := range rows { + d := r.d + fields := []string{ + derefString(d.DirectConnectId), + derefString(d.DirectConnectName), + derefString(d.State), + derefString(d.AccessPointId), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newDCClient(c *Client, region string) (*dc.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("dc.tencentcloudapi.com") + return dc.NewClient(cred, region, cpf) +} diff --git a/internal/tencent/observability.go b/internal/tencent/observability.go new file mode 100644 index 0000000..312e59d --- /dev/null +++ b/internal/tencent/observability.go @@ -0,0 +1,222 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + cloudaudit "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit/v20190319" + cls "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls/v20201016" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + monitor "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor/v20180724" +) + +// listAlarmPolicies prints every Cloud Monitor alarm policy in the region. +// Each policy has a list of trigger conditions; we show count + enable state. +func listAlarmPolicies(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + p *monitor.AlarmPolicy + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newMonitorClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init monitor client: %v", r, err)) + continue + } + req := monitor.NewDescribeAlarmPoliciesRequest() + module := "monitor" + req.Module = &module + var page, pageSize int64 = 1, 100 + req.PageNumber = &page + req.PageSize = &pageSize + resp, err := client.DescribeAlarmPolicies(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, p := range resp.Response.Policies { + rows = append(rows, row{region: r, p: p}) + } + } + + header := fmt.Sprintf("Cloud Monitor Alarm Policies (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Cloud Monitor Alarm Policies (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No alarm policies found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tPOLICY_ID\tNAME\tENABLED\tTYPE\tBOUND_INSTANCES") + } else { + fmt.Fprintln(tw, "POLICY_ID\tNAME\tENABLED\tTYPE\tBOUND_INSTANCES") + } + for _, r := range rows { + p := r.p + enabled := derefInt64(p.Enable) == 1 + fields := []string{ + derefString(p.PolicyId), + derefString(p.PolicyName), + fmt.Sprintf("%v", enabled), + derefString(p.MonitorType), + fmt.Sprintf("%d", derefInt64(p.UseSum)), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newMonitorClient(c *Client, region string) (*monitor.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("monitor.tencentcloudapi.com") + return monitor.NewClient(cred, region, cpf) +} + +// listCLSTopics prints every CLS log topic in the region. +func listCLSTopics(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + t *cls.TopicInfo + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newCLSClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init cls client: %v", r, err)) + continue + } + resp, err := client.DescribeTopics(cls.NewDescribeTopicsRequest()) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, t := range resp.Response.Topics { + rows = append(rows, row{region: r, t: t}) + } + } + + header := fmt.Sprintf("CLS Log Topics (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("CLS Log Topics (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No log topics found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tTOPIC_ID\tNAME\tLOGSET_ID\tPARTITIONS\tINDEX\tCREATED") + } else { + fmt.Fprintln(tw, "TOPIC_ID\tNAME\tLOGSET_ID\tPARTITIONS\tINDEX\tCREATED") + } + for _, r := range rows { + t := r.t + fields := []string{ + derefString(t.TopicId), + derefString(t.TopicName), + derefString(t.LogsetId), + fmt.Sprintf("%d", derefInt64(t.PartitionCount)), + fmt.Sprintf("%v", derefBool(t.Index)), + derefString(t.CreateTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newCLSClient(c *Client, region string) (*cls.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cls.tencentcloudapi.com") + return cls.NewClient(cred, region, cpf) +} + +// listCloudAuditTracks prints every Cloud Audit "track" (API call log). +// Tracks are account-global; the region argument is for the API endpoint. +func listCloudAuditTracks(c *Client) error { + client, err := newCloudAuditClient(c) + if err != nil { + return fmt.Errorf("init cloudaudit client: %w", err) + } + resp, err := client.ListAudits(cloudaudit.NewListAuditsRequest()) + if err != nil { + return fmt.Errorf("ListAudits: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud Audit Tracks:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.AuditSummarys) == 0 { + fmt.Println(" No Cloud Audit tracks configured") + fmt.Println() + fmt.Println(" ⚠️ Without audit tracks, API calls against this account are not logged.") + fmt.Println(" Enable Cloud Audit + a COS bucket destination to capture who-did-what.") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tSTATUS\tCOS_BUCKET\tLOG_PREFIX") + for _, a := range resp.Response.AuditSummarys { + enabled := derefInt64(a.AuditStatus) == 1 + status := "DISABLED" + if enabled { + status = "ENABLED" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + derefString(a.AuditName), + status, + derefString(a.CosBucketName), + derefString(a.LogFilePrefix), + ) + } + return tw.Flush() +} + +func newCloudAuditClient(c *Client) (*cloudaudit.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("cloudaudit.tencentcloudapi.com") + return cloudaudit.NewClient(cred, "ap-guangzhou", cpf) +} diff --git a/internal/tencent/profile.go b/internal/tencent/profile.go new file mode 100644 index 0000000..80968a9 --- /dev/null +++ b/internal/tencent/profile.go @@ -0,0 +1,61 @@ +package tencent + +import ( + "context" + "log" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile" +) + +// gatherPageSize is the Tencent SDK's standard per-page limit for inventory +// Describe* calls. Most APIs cap their own response at 100; a few accept +// higher (CDB up to 2000) but 100 is the safe lowest-common-denominator. +const gatherPageSize = 100 + +// gatherMaxItems caps the total number of items any single gather function +// will accumulate across paginated calls. Production accounts can have +// thousands of resources, and we'd rather truncate the LLM context (with a +// visible warning) than stream a 50 MB JSON blob into the prompt window. +// When this fires, the caller logs "(showing first N of M)" alongside the +// data so consumers know they're seeing a partial view. +const gatherMaxItems = 1000 + +// ctxDone returns ctx.Err() if the caller has cancelled, otherwise nil. +// The Tencent SDK doesn't expose WithContext variants for its typed clients, +// so we can't actually interrupt a request in flight — but we can avoid +// firing the NEXT pagination page (or the next section of GetRelevantContext) +// after the caller has cancelled. Combined with the ReqTimeout in +// newClientProfile, this bounds the worst-case wall-clock cost of a +// cancelled gather to one in-flight SDK call. +func ctxDone(ctx context.Context) error { + if ctx == nil { + return nil + } + return ctx.Err() +} + +// logGatherTruncated reports that a paginated gather stopped early because +// it hit gatherMaxItems. Logged at the stdlib default logger (stderr) so +// operators see it in dev compose logs and clanker-api stderr in prod; +// kept off the JSON output so the wire shape stays a flat []item. +func logGatherTruncated(resourceType, region string, totalReported int64, accumulated int) { + log.Printf("[tencent] %s in %s: truncated at %d of %d (gatherMaxItems cap; LLM context will be incomplete)", + resourceType, region, accumulated, totalReported) +} + +// tencentReqTimeoutSec bounds every Tencent SDK HTTP call. The SDK exposes +// no WithContext variants, so caller ctx cancellation can't actually +// interrupt a request mid-flight — but ReqTimeout ensures a hung call +// can't pin a request handler forever. Set generously enough that legitimate +// slow APIs (billing, audit-coverage scan) still complete. +const tencentReqTimeoutSec = 30 + +// newClientProfile builds the SDK ClientProfile every typed service client +// shares: a service endpoint plus the standard ReqTimeout. Use this from +// every per-service constructor so the timeout is uniform. +func newClientProfile(endpoint string) *profile.ClientProfile { + cpf := profile.NewClientProfile() + cpf.HttpProfile.Endpoint = endpoint + cpf.HttpProfile.ReqTimeout = tencentReqTimeoutSec + return cpf +} diff --git a/internal/tencent/raw.go b/internal/tencent/raw.go new file mode 100644 index 0000000..b90ac72 --- /dev/null +++ b/internal/tencent/raw.go @@ -0,0 +1,150 @@ +//go:generate go run gen_services.go + +package tencent + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + tchttp "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/http" +) + +// serviceVersions is generated from the vendored Tencent SDK by +// gen_services.go — see service_versions_gen.go. Don't edit it by hand; +// run `go generate ./internal/tencent/...` after upgrading the SDK. + +// knownHallucinatedActions maps LLM-invented action names to the real Tencent +// action they probably meant. Curated empirically from Qwen3 maker output — +// keep this list short and high-confidence. The point is to fail FAST with a +// useful message instead of paying a Tencent round-trip for a definite typo. +// +// Key is "service.action" lowercased. Value is a human-readable hint string. +var knownHallucinatedActions = map[string]string{ + "monitor.getproductmetricdata": "Use GetMonitorData. Tencent's Monitor service has no GetProductMetricData action.", + "monitor.describemonitordata": "Use GetMonitorData. Tencent's Monitor service has no DescribeMonitorData action.", + "monitor.getproductmetrics": "Use GetMonitorData or DescribeBaseMetrics.", + "monitor.describemetricdata": "Use GetMonitorData.", + "monitor.describealarmpolicies": "Use DescribeAlarmPolicy (singular).", + "billing.describebillsummary": "Use DescribeBillSummaryByProduct, DescribeBillSummaryByPayMode, or DescribeBillSummaryByRegion.", + "billing.describeresourcebills": "Use DescribeBillResourceSummary or DescribeBillDetail.", + "cvm.describeinstancestate": "Use DescribeInstancesStatus.", + "cvm.listinstances": "Use DescribeInstances (Tencent's discovery actions are always Describe*, never List*).", + "vpc.listvpcs": "Use DescribeVpcs.", + "cls.describetopics": "Use DescribeTopics — make sure your service is `cls`, not `log`.", +} + +// SendRaw makes a generic Tencent API call. Used by maker plan execution and +// any future agent path that wants to invoke an action by string name. +// +// The request body is the JSON-encoded action parameters Tencent's API expects +// (matching the SDK request struct fields). On success the returned string is +// the raw JSON response body. +func (c *Client) SendRaw(service, action, region, paramsJSON string) (string, error) { + service = strings.ToLower(strings.TrimSpace(service)) + action = strings.TrimSpace(action) + region = strings.TrimSpace(region) + + // Fail fast on known-invented action names with a "did you mean" hint. + // Tencent's own error is the generic "InvalidAction" which doesn't help + // the user (or the LLM, on a retry) understand what to fix. + if hint, bad := knownHallucinatedActions[service+"."+strings.ToLower(action)]; bad { + return "", fmt.Errorf("action %q is not a real Tencent %s API action — %s", action, service, hint) + } + + version, ok := serviceVersions[service] + if !ok { + return "", fmt.Errorf("unsupported tencent service %q (known: %s)", service, knownServices()) + } + if version == "" { + return "", fmt.Errorf("service %q does not use the generic action API (use service-specific path)", service) + } + if action == "" { + return "", fmt.Errorf("action is required") + } + if region == "" { + region = c.creds.Region + } + + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile(service + ".tencentcloudapi.com") + client := common.NewCommonClient(cred, region, cpf) + + req := tchttp.NewCommonRequest(service, version, action) + if strings.TrimSpace(paramsJSON) != "" { + if len(paramsJSON) > maxParamsJSONBytes { + return "", fmt.Errorf("params JSON too large (%d bytes; cap %d)", len(paramsJSON), maxParamsJSONBytes) + } + var params map[string]interface{} + if err := json.Unmarshal([]byte(paramsJSON), ¶ms); err != nil { + return "", fmt.Errorf("invalid params JSON: %w", err) + } + if err := checkParamsFieldSize(params, ""); err != nil { + return "", err + } + if err := req.SetActionParameters(params); err != nil { + return "", fmt.Errorf("set action parameters: %w", err) + } + } + + resp := tchttp.NewCommonResponse() + if err := client.Send(req, resp); err != nil { + return "", friendlyError(err) + } + return string(resp.GetBody()), nil +} + +// maxParamsJSONBytes / maxParamsFieldBytes bound the LLM-supplied paramsJSON +// for SendRaw. Effective ceiling without these would be the HTTP body limit +// (~1 MiB), which is far larger than any legitimate Tencent action payload. +// 64 KiB per field accommodates user-data scripts and policy documents while +// rejecting accidentally-pasted dumps that would inflate the LLM context. +const ( + maxParamsJSONBytes = 256 * 1024 // total payload cap + maxParamsFieldBytes = 64 * 1024 // per-string-field cap +) + +// checkParamsFieldSize walks the parsed params and rejects any string field +// (recursing into nested maps and slices) that exceeds maxParamsFieldBytes. +// path is the dotted location used in the error message so the caller knows +// which field tripped the limit. +func checkParamsFieldSize(v interface{}, path string) error { + switch x := v.(type) { + case string: + if len(x) > maxParamsFieldBytes { + return fmt.Errorf("params field %q too large (%d bytes; cap %d)", path, len(x), maxParamsFieldBytes) + } + case map[string]interface{}: + for k, val := range x { + sub := k + if path != "" { + sub = path + "." + k + } + if err := checkParamsFieldSize(val, sub); err != nil { + return err + } + } + case []interface{}: + for i, val := range x { + sub := fmt.Sprintf("%s[%d]", path, i) + if err := checkParamsFieldSize(val, sub); err != nil { + return err + } + } + } + return nil +} + +// knownServices returns a comma-separated, sorted list of services in the +// generated map — used in error messages so the list stays accurate as the +// SDK gains or drops services. +func knownServices() string { + keys := make([]string, 0, len(serviceVersions)) + for k := range serviceVersions { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} diff --git a/internal/tencent/redis.go b/internal/tencent/redis.go new file mode 100644 index 0000000..7ce522e --- /dev/null +++ b/internal/tencent/redis.go @@ -0,0 +1,124 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + redis "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis/v20180412" +) + +// listRedis prints every TencentDB for Redis instance across regions. +// WanAddress is the security-critical field — when non-empty it means the +// instance is reachable from the public internet. +func listRedis(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + i *redis.InstanceSet + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newRedisClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init redis client: %v", r, err)) + continue + } + req := redis.NewDescribeInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeInstances(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, inst := range resp.Response.InstanceSet { + rows = append(rows, row{region: r, i: inst}) + } + } + + header := fmt.Sprintf("TencentDB for Redis (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("TencentDB for Redis (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No Redis instances found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tINSTANCE_ID\tNAME\tSTATUS\tSIZE_MB\tVIP:PORT\tPUBLIC\tCREATED") + } else { + fmt.Fprintln(tw, "INSTANCE_ID\tNAME\tSTATUS\tSIZE_MB\tVIP:PORT\tPUBLIC\tCREATED") + } + for _, r := range rows { + i := r.i + size := int64(0) + if i.Size != nil { + size = int64(*i.Size) + } + pub := "-" + if w := strings.TrimSpace(derefString(i.WanAddress)); w != "" && w != "-" { + pub = w + } + fields := []string{ + derefString(i.InstanceId), + derefString(i.InstanceName), + redisStatus(i.Status), + fmt.Sprintf("%d", size), + fmt.Sprintf("%s:%d", derefString(i.WanIp), derefInt64(i.Port)), + pub, + derefString(i.Createtime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func newRedisClient(c *Client, region string) (*redis.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("redis.tencentcloudapi.com") + return redis.NewClient(cred, region, cpf) +} + +func redisStatus(p *int64) string { + if p == nil { + return "-" + } + switch *p { + case 0: + return "PENDING_INIT" + case 1: + return "PROCESSING" + case 2: + return "RUNNING" + case -2: + return "ISOLATED" + case -3: + return "PENDING_DELETE" + default: + return fmt.Sprintf("STATE-%d", *p) + } +} diff --git a/internal/tencent/security_scan.go b/internal/tencent/security_scan.go new file mode 100644 index 0000000..4026fd5 --- /dev/null +++ b/internal/tencent/security_scan.go @@ -0,0 +1,172 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// ExposedCVM is one CVM with a public IP plus the union of risky inbound +// rules across the security groups attached to it. The frontend renders one +// table row per ExposedCVM. +type ExposedCVM struct { + InstanceID string `json:"instance_id"` + Name string `json:"name"` + State string `json:"state"` + PublicIP string `json:"public_ip"` + PrivateIP string `json:"private_ip,omitempty"` + SGIDs []string `json:"sg_ids"` + RiskyRules []ExposedRule `json:"risky_rules"` +} + +// ExposedRule attributes a single risky rule to a (CVM, SG) pair. Port and +// risk strings match the classifier in vpc.go (PUBLIC-SSH, PUBLIC-MySQL, etc). +type ExposedRule struct { + SGID string `json:"sg_id"` + SGName string `json:"sg_name,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port string `json:"port,omitempty"` + Source string `json:"source,omitempty"` + Risk string `json:"risk"` + Description string `json:"description,omitempty"` +} + +// PublicExposureScanJSON returns a flat list of CVMs that have a public IP +// AND have at least one attached SG with an ingress rule allowing 0.0.0.0/0 +// (or ::/0) on a sensitive port. The classifier reuses classifySGRule and +// sensitivePorts from vpc.go so labels are consistent with `tencent sg-rules`. +func (c *Client) PublicExposureScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + cli, err := c.CVM() + if err != nil { + return "", err + } + req := cvm.NewDescribeInstancesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cli.DescribeInstances(req) + if err != nil { + return "", friendlyError(err) + } + if resp == nil || resp.Response == nil { + empty, _ := json.Marshal(struct { + Region string `json:"region"` + Items []ExposedCVM `json:"items"` + }{c.Region(), []ExposedCVM{}}) + return string(empty), nil + } + + // Index SGs by ID so we only fetch each unique SG's rules once. + sgRules := map[string]*vpc.SecurityGroupPolicySet{} + sgNames := map[string]string{} + vpcCli, err := c.VPC() + if err != nil { + return "", err + } + + var exposed []ExposedCVM + for _, in := range resp.Response.InstanceSet { + pub := firstIP(in.PublicIpAddresses) + if pub == "" { + continue + } + sgIDs := stringSlice(in.SecurityGroupIds) + var risks []ExposedRule + for _, sgID := range sgIDs { + rules, ok := sgRules[sgID] + if !ok { + rr, name, ferr := fetchSGRules(vpcCli, sgID) + if ferr != nil { + // Skip this SG but keep processing others. + sgRules[sgID] = nil + continue + } + sgRules[sgID] = rr + sgNames[sgID] = name + rules = rr + } + if rules == nil { + continue + } + for _, p := range rules.Ingress { + risk := classifySGRule(p, true) + if risk == "" { + continue + } + risks = append(risks, ExposedRule{ + SGID: sgID, + SGName: sgNames[sgID], + Protocol: derefStringRaw(p.Protocol), + Port: derefStringRaw(p.Port), + Source: sourceCIDR(p), + Risk: risk, + Description: derefStringRaw(p.PolicyDescription), + }) + } + } + if len(risks) == 0 { + continue + } + row := ExposedCVM{ + InstanceID: derefStringRaw(in.InstanceId), + Name: derefStringRaw(in.InstanceName), + State: derefStringRaw(in.InstanceState), + PublicIP: pub, + PrivateIP: firstIP(in.PrivateIpAddresses), + SGIDs: sgIDs, + RiskyRules: risks, + } + exposed = append(exposed, row) + } + + out := struct { + Region string `json:"region"` + Items []ExposedCVM `json:"items"` + }{ + Region: c.Region(), + Items: exposed, + } + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +func fetchSGRules(vpcCli *vpc.Client, sgID string) (*vpc.SecurityGroupPolicySet, string, error) { + pReq := vpc.NewDescribeSecurityGroupPoliciesRequest() + pReq.SecurityGroupId = &sgID + pResp, err := vpcCli.DescribeSecurityGroupPolicies(pReq) + if err != nil { + return nil, "", fmt.Errorf("DescribeSecurityGroupPolicies(%s): %w", sgID, friendlyError(err)) + } + if pResp == nil || pResp.Response == nil { + return nil, "", nil + } + // Optionally pull the SG name from the DescribeSecurityGroups output — + // kept cheap by skipping unless the caller wants it. + return pResp.Response.SecurityGroupPolicySet, "", nil +} + +func sourceCIDR(p *vpc.SecurityGroupPolicy) string { + if p == nil { + return "" + } + if v := derefStringRaw(p.CidrBlock); v != "" { + return v + } + if v := derefStringRaw(p.Ipv6CidrBlock); v != "" { + return v + } + if p.SecurityGroupId != nil { + return "sg:" + *p.SecurityGroupId + } + return "" +} diff --git a/internal/tencent/security_scan_p10.go b/internal/tencent/security_scan_p10.go new file mode 100644 index 0000000..2ad1d5a --- /dev/null +++ b/internal/tencent/security_scan_p10.go @@ -0,0 +1,361 @@ +package tencent + +import ( + "context" + "encoding/json" + "strings" + + cam "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam/v20190116" + cbs "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs/v20170312" + clb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb/v20180317" + ssl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// CLBExposureScanJSON flags every public-facing CLB ("OPEN" type) and notes +// which of its listeners are on sensitive ports. Listener-level rules are +// fetched per-LB which makes this slightly slower than the EIP/CBS audits. +func (c *Client) CLBExposureScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + cli, err := newCLBClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := clb.NewDescribeLoadBalancersRequest() + openType := "OPEN" + req.LoadBalancerType = &openType + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cli.DescribeLoadBalancers(req) + if err != nil { + return "", friendlyError(err) + } + + type listenerRow struct { + ListenerID string `json:"listener_id"` + Name string `json:"name,omitempty"` + Protocol string `json:"protocol"` + Port int64 `json:"port"` + Risk string `json:"risk,omitempty"` + } + type cveLB struct { + LBID string `json:"lb_id"` + Name string `json:"name,omitempty"` + Type string `json:"type"` + VIPs []string `json:"vips,omitempty"` + Listeners []listenerRow `json:"listeners"` + RiskyCount int `json:"risky_count"` + } + + var items []cveLB + if resp != nil && resp.Response != nil { + for _, lb := range resp.Response.LoadBalancerSet { + lbID := derefStringRaw(lb.LoadBalancerId) + listeners, _ := fetchCLBListeners(c, c.Region(), lbID) + row := cveLB{ + LBID: lbID, + Name: derefStringRaw(lb.LoadBalancerName), + Type: derefStringRaw(lb.LoadBalancerType), + VIPs: stringSlice(lb.LoadBalancerVips), + } + for _, l := range listeners { + proto := strings.ToUpper(derefStringRaw(l.Protocol)) + port := int64(0) + if l.Port != nil { + port = *l.Port + } + risk := classifyCLBListenerRisk(proto, port) + row.Listeners = append(row.Listeners, listenerRow{ + ListenerID: derefStringRaw(l.ListenerId), + Name: derefStringRaw(l.ListenerName), + Protocol: proto, + Port: port, + Risk: risk, + }) + if risk != "" { + row.RiskyCount++ + } + } + // Only include LBs that are publicly addressable. We already + // filtered by Type="OPEN" but a defence-in-depth check costs nothing. + items = append(items, row) + } + } + + out := struct { + Region string `json:"region"` + Items []cveLB `json:"items"` + }{c.Region(), items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// classifyCLBListenerRisk returns a risk label when a CLB listener exposes a +// sensitive port. Since CLB listeners by definition serve all of 0.0.0.0/0 +// (the VIP is global on OPEN-type LBs), the audit reduces to "is the port +// sensitive?". +func classifyCLBListenerRisk(proto string, port int64) string { + if proto == "HTTP" || proto == "HTTPS" { + // Standard web ports are intentional; flag only weird ones. + if port == 22 || port == 3306 || port == 5432 || port == 6379 || port == 27017 { + return "WEB-on-DB-PORT" + } + return "" + } + if proto == "TCP" || proto == "UDP" || proto == "TCP_SSL" { + switch port { + case 22: + return "PUBLIC-SSH" + case 3389: + return "PUBLIC-RDP" + case 3306: + return "PUBLIC-MySQL" + case 5432: + return "PUBLIC-PostgreSQL" + case 6379: + return "PUBLIC-Redis" + case 9200: + return "PUBLIC-Elasticsearch" + case 27017: + return "PUBLIC-MongoDB" + } + } + return "" +} + +// IdleEIPScanJSON flags every EIP not bound to a resource. These leak +// budget and (since they keep their IP) historical reputation. +func (c *Client) IdleEIPScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + cli, err := c.VPC() + if err != nil { + return "", err + } + req := vpc.NewDescribeAddressesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cli.DescribeAddresses(req) + if err != nil { + return "", friendlyError(err) + } + type idleEIP struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + IP string `json:"ip"` + Status string `json:"status"` + Type string `json:"type,omitempty"` + Created string `json:"created_at,omitempty"` + } + var items []idleEIP + if resp != nil && resp.Response != nil { + for _, a := range resp.Response.AddressSet { + st := strings.ToUpper(derefStringRaw(a.AddressStatus)) + if st != "UNBIND" { + continue + } + items = append(items, idleEIP{ + ID: derefStringRaw(a.AddressId), + Name: derefStringRaw(a.AddressName), + IP: derefStringRaw(a.AddressIp), + Status: derefStringRaw(a.AddressStatus), + Type: derefStringRaw(a.AddressType), + Created: derefStringRaw(a.CreatedTime), + }) + } + } + out := struct { + Region string `json:"region"` + Items []idleEIP `json:"items"` + }{c.Region(), items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// UnencryptedCBSScanJSON flags any CBS volume where Encrypt==false. +// Volumes that are unattached AND unencrypted are doubly flagged because +// they're cost waste plus potential data exposure if a snapshot is shared. +func (c *Client) UnencryptedCBSScanJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + cli, err := newCBSClient(c, c.creds.Region) + if err != nil { + return "", err + } + req := cbs.NewDescribeDisksRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cli.DescribeDisks(req) + if err != nil { + return "", friendlyError(err) + } + type diskRow struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Type string `json:"type"` + SizeGB uint64 `json:"size_gb"` + State string `json:"state"` + InstanceID string `json:"instance_id,omitempty"` + Zone string `json:"zone,omitempty"` + Unattached bool `json:"unattached"` + } + var items []diskRow + if resp != nil && resp.Response != nil { + for _, d := range resp.Response.DiskSet { + if derefBool(d.Encrypt) { + continue + } + zone := "" + if d.Placement != nil { + zone = derefStringRaw(d.Placement.Zone) + } + state := derefStringRaw(d.DiskState) + items = append(items, diskRow{ + ID: derefStringRaw(d.DiskId), + Name: derefStringRaw(d.DiskName), + Type: derefStringRaw(d.DiskType), + SizeGB: derefUint64Raw(d.DiskSize), + State: state, + InstanceID: derefStringRaw(d.InstanceId), + Zone: zone, + Unattached: strings.EqualFold(state, "UNATTACHED"), + }) + } + } + out := struct { + Region string `json:"region"` + Items []diskRow `json:"items"` + }{c.Region(), items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// CertExpiryScanJSON flags SSL certificates expiring within `days` days. +// Negative DaysLeft means already expired. +func (c *Client) CertExpiryScanJSON(ctx context.Context, days int) (string, error) { + if days <= 0 { + days = 30 + } + cli, err := newSSLClient(c) + if err != nil { + return "", err + } + req := ssl.NewDescribeCertificatesRequest() + var offset, limit uint64 = 0, 200 + req.Offset = &offset + req.Limit = &limit + resp, err := cli.DescribeCertificates(req) + if err != nil { + return "", friendlyError(err) + } + type certRow struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Domain string `json:"domain,omitempty"` + Status string `json:"status"` + CertEnd string `json:"cert_end,omitempty"` + DaysLeft int `json:"days_left"` + } + var items []certRow + if resp != nil && resp.Response != nil { + for _, cert := range resp.Response.Certificates { + d := daysUntilExpiry(cert.CertEndTime) + if d > days { + continue + } + items = append(items, certRow{ + ID: derefStringRaw(cert.CertificateId), + Alias: derefStringRaw(cert.Alias), + Domain: derefStringRaw(cert.Domain), + Status: sslStatus(cert.Status), + CertEnd: derefStringRaw(cert.CertEndTime), + DaysLeft: d, + }) + } + } + out := struct { + ThresholdDays int `json:"threshold_days"` + Items []certRow `json:"items"` + }{days, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// CAMHygieneScanJSON flags sub-accounts with console_login enabled but no +// phone number registered (so MFA via phone is impossible). The SDK at this +// version doesn't expose the canonical MFA flags so this is a heuristic that +// catches the common "service account accidentally given console access" +// case. +func (c *Client) CAMHygieneScanJSON(ctx context.Context) (string, error) { + cli, err := newCAMClient(c) + if err != nil { + return "", err + } + resp, err := cli.ListUsers(cam.NewListUsersRequest()) + if err != nil { + return "", friendlyError(err) + } + type userRow struct { + UID uint64 `json:"uid"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + ConsoleLogin bool `json:"console_login"` + PhoneRegistered bool `json:"phone_registered"` + Findings []string `json:"findings"` + } + var items []userRow + totalUsers := 0 + if resp != nil && resp.Response != nil { + totalUsers = len(resp.Response.Data) + for _, u := range resp.Response.Data { + console := derefUint64Raw(u.ConsoleLogin) == 1 + phone := strings.TrimSpace(derefStringRaw(u.PhoneNum)) != "" + var findings []string + if console && !phone { + findings = append(findings, "console-login-without-phone") + } + if console && strings.TrimSpace(derefStringRaw(u.Email)) == "" { + findings = append(findings, "console-login-without-email") + } + if len(findings) == 0 { + continue + } + items = append(items, userRow{ + UID: derefUint64Raw(u.Uid), + Name: derefStringRaw(u.Name), + Email: derefStringRaw(u.Email), + ConsoleLogin: console, + PhoneRegistered: phone, + Findings: findings, + }) + } + } + out := struct { + TotalUsers int `json:"total_users"` + Items []userRow `json:"items"` + }{totalUsers, items} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/tencent/service_versions_gen.go b/internal/tencent/service_versions_gen.go new file mode 100644 index 0000000..59b9b25 --- /dev/null +++ b/internal/tencent/service_versions_gen.go @@ -0,0 +1,36 @@ +// Code generated by gen_services.go; DO NOT EDIT. +// Run `go generate ./internal/tencent/...` to refresh after upgrading +// github.com/tencentcloud/tencentcloud-sdk-go. + +package tencent + +// serviceVersions maps Tencent service short names to the API version +// SendRaw passes to tchttp.CommonRequest. Source: the vendored Tencent +// SDK, picking the highest version per service. Empty string means the +// service does not use the generic action API (see SendRaw for the +// branch that handles this). +var serviceVersions = map[string]string{ + "antiddos": "2025-09-03", + "billing": "2018-07-09", + "cam": "2019-01-16", + "cbs": "2017-03-12", + "cdb": "2017-03-20", + "cdn": "2018-06-06", + "clb": "2018-03-17", + "cloudaudit": "2019-03-19", + "cls": "2020-10-16", + "cos": "", + "cvm": "2017-03-12", + "cynosdb": "2019-01-07", + "dc": "2018-04-10", + "lighthouse": "2020-03-24", + "mongodb": "2019-07-25", + "monitor": "2023-06-16", + "postgres": "2017-03-12", + "redis": "2018-04-12", + "ssl": "2019-12-05", + "teo": "2022-09-01", + "tke": "2022-05-01", + "vpc": "2017-03-12", + "waf": "2018-01-25", +} diff --git a/internal/tencent/ssl.go b/internal/tencent/ssl.go new file mode 100644 index 0000000..9257de9 --- /dev/null +++ b/internal/tencent/ssl.go @@ -0,0 +1,105 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + ssl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205" +) + +// listSSLCerts prints every SSL certificate the account owns. SSL is a +// global service so no region is needed. +func listSSLCerts(c *Client) error { + client, err := newSSLClient(c) + if err != nil { + return fmt.Errorf("init ssl client: %w", err) + } + req := ssl.NewDescribeCertificatesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeCertificates(req) + if err != nil { + return fmt.Errorf("DescribeCertificates: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud SSL Certificates:") + fmt.Println() + if resp == nil || resp.Response == nil || len(resp.Response.Certificates) == 0 { + fmt.Println(" No SSL certificates found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "CERT_ID\tALIAS\tDOMAIN\tSTATUS\tFROM\tEXPIRES\tDAYS_LEFT") + for _, cert := range resp.Response.Certificates { + days := daysUntilExpiry(cert.CertEndTime) + daysStr := fmt.Sprintf("%d", days) + if days < 0 { + daysStr = fmt.Sprintf("EXPIRED %dd ago", -days) + } else if days < 30 { + daysStr = fmt.Sprintf("⚠ %dd", days) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + derefString(cert.CertificateId), + derefString(cert.Alias), + derefString(cert.Domain), + sslStatus(cert.Status), + derefString(cert.From), + derefString(cert.CertEndTime), + daysStr, + ) + } + return tw.Flush() +} + +func newSSLClient(c *Client) (*ssl.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("ssl.tencentcloudapi.com") + // SSL is global; pass an arbitrary region (the service ignores it). + return ssl.NewClient(cred, "ap-guangzhou", cpf) +} + +func sslStatus(p *uint64) string { + if p == nil { + return "-" + } + switch *p { + case 0: + return "REVIEWING" + case 1: + return "ISSUED" + case 2: + return "REVIEW_FAILED" + case 3: + return "EXPIRED" + case 10: + return "REVOKED" + default: + return fmt.Sprintf("STATE-%d", *p) + } +} + +// daysUntilExpiry parses Tencent's "YYYY-MM-DD HH:mm:ss" (GMT+8) format and +// returns the integer day delta from now. Negative when expired. +func daysUntilExpiry(end *string) int { + if end == nil || strings.TrimSpace(*end) == "" { + return 0 + } + loc, _ := time.LoadLocation("Asia/Shanghai") + if loc == nil { + loc = time.UTC + } + layouts := []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z", time.RFC3339} + for _, layout := range layouts { + t, err := time.ParseInLocation(layout, strings.TrimSpace(*end), loc) + if err == nil { + return int(time.Until(t).Hours() / 24) + } + } + return 0 +} diff --git a/internal/tencent/static_commands.go b/internal/tencent/static_commands.go new file mode 100644 index 0000000..767df5b --- /dev/null +++ b/internal/tencent/static_commands.go @@ -0,0 +1,327 @@ +package tencent + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CreateTencentCommands wires the `clanker tencent` subtree. +func CreateTencentCommands() *cobra.Command { + tencentCmd := &cobra.Command{ + Use: "tencent", + Short: "Query Tencent Cloud infrastructure directly", + Long: "Query your Tencent Cloud infrastructure without AI interpretation. Useful for getting raw data.", + Aliases: []string{"tc", "tencentcloud"}, + } + + var region string + tencentCmd.PersistentFlags().StringVar(®ion, "region", "", "Tencent Cloud region (default from config / TENCENTCLOUD_REGION / TENCENT_REGION / ap-singapore)") + + var allRegions bool + listCmd := &cobra.Command{ + Use: "list [resource]", + Short: "List Tencent Cloud resources", + Long: `List Tencent Cloud resources of a specific type. + +Supported resources: + cvm, instances - Cloud Virtual Machine instances + vpc, vpcs - Virtual Private Clouds + subnets, subnet - VPC subnets + security-groups, sg, sgs - Security Groups + mysql, cdb - TencentDB for MySQL instances + postgres, pg, postgresql - TencentDB for PostgreSQL instances + cos, buckets - COS object storage buckets (service-global) + tke, k8s, clusters - TKE (Tencent Kubernetes Engine) clusters + clb, lbs, lb - Cloud Load Balancers + eip, eips, addresses - Elastic IPs + cbs, disks, volumes - Cloud Block Storage volumes + ssl, certs, certificates - SSL certificates (service-global) + cam, iam, users - CAM sub-account users (account-global) + redis, valkey - TencentDB for Redis instances + mongo, mongodb - TencentDB for MongoDB instances + cynosdb, tdsql-c - CynosDB (TDSQL-C) clusters + cdn, cdn-domains - CDN accelerated domains (account-global) + edgeone, teo, zones - EdgeOne (TEO) zones (account-global) + waf, waf-hosts - WAF-protected hosts (account-global) + antiddos, ddos - Anti-DDoS Advanced (BGP-IP) instances + nat, nat-gateway - NAT gateways + vpn, vpn-gateway - VPN gateways + ccn - Cloud Connect Networks (account-global) + dc, direct-connect - Direct Connect physical lines + monitor, alarms - Cloud Monitor alarm policies + cls, logs - CLS log topics + cloudaudit, audit, tracks - Cloud Audit tracks (API-call log config) + +Use --all-regions to fan out across every available region (does not apply +to cos, which uses a service-global endpoint).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resourceType := strings.ToLower(strings.TrimSpace(args[0])) + + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + + debug := viper.GetBool("debug") + client, err := NewClient(creds, debug) + if err != nil { + return err + } + + regions := []string{client.Region()} + if allRegions { + all, err := client.ListAllRegions() + if err != nil { + return fmt.Errorf("list regions: %w", err) + } + if len(all) > 0 { + regions = all + } + if debug { + fmt.Printf("[tencent] fanning out across %d regions\n", len(regions)) + } + } + + switch resourceType { + case "cvm", "instance", "instances", "vm", "vms": + return listCVM(client, regions) + case "vpc", "vpcs": + return listVPCs(client, regions) + case "subnet", "subnets": + return listSubnets(client, regions) + case "sg", "sgs", "security-group", "security-groups": + return listSecurityGroups(client, regions) + case "mysql", "cdb": + return listMySQL(client, regions) + case "postgres", "postgresql", "pg": + return listPostgres(client, regions) + case "cos", "bucket", "buckets": + return listCOSBuckets(client) + case "tke", "k8s", "cluster", "clusters", "kubernetes": + return listTKEClusters(client, regions) + case "clb", "lb", "lbs", "load-balancer", "load-balancers": + return listCLBs(client, regions) + case "eip", "eips", "address", "addresses": + return listEIPs(client, regions) + case "cbs", "disk", "disks", "volume", "volumes": + return listCBS(client, regions) + case "ssl", "cert", "certs", "certificate", "certificates": + return listSSLCerts(client) + case "cam", "iam", "user", "users": + return listCAMUsers(client) + case "redis", "valkey": + return listRedis(client, regions) + case "mongo", "mongodb": + return listMongoDB(client, regions) + case "cynosdb", "tdsql-c", "tdsqlc": + return listCynosDB(client, regions) + case "cdn", "cdn-domains": + return listCDNDomains(client) + case "edgeone", "teo", "zones": + return listEdgeOneZones(client) + case "waf", "waf-hosts": + return listWAFHosts(client) + case "antiddos", "ddos": + return listAntiDDoS(client) + case "nat", "nat-gateway", "natgateway": + return listNATGateways(client, regions) + case "vpn", "vpn-gateway", "vpngateway": + return listVPNGateways(client, regions) + case "ccn", "cloud-connect": + return listCCNs(client) + case "dc", "direct-connect", "directconnect": + return listDirectConnects(client, regions) + case "monitor", "alarm", "alarms", "alarm-policy": + return listAlarmPolicies(client, regions) + case "cls", "log", "logs", "log-topics": + return listCLSTopics(client, regions) + case "cloudaudit", "audit", "tracks": + return listCloudAuditTracks(client) + default: + return fmt.Errorf("unknown resource type: %s (supported: cvm, vpc, subnets, security-groups, mysql, postgres, cos, tke, clb, eip, cbs, ssl, cam, redis, mongodb, cynosdb, cdn, edgeone, waf, antiddos, nat, vpn, ccn, dc, monitor, cls, cloudaudit)", resourceType) + } + }, + } + listCmd.Flags().BoolVar(&allRegions, "all-regions", false, "Query every available Tencent region and merge the results") + + regionsCmd := &cobra.Command{ + Use: "regions", + Short: "List all Tencent Cloud regions available to this credential", + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + all, err := client.ListAllRegions() + if err != nil { + return err + } + fmt.Printf("Tencent Cloud regions (%d):\n\n", len(all)) + for _, r := range all { + fmt.Println(" " + r) + } + return nil + }, + } + + sgRulesCmd := &cobra.Command{ + Use: "sg-rules [security-group-id]", + Short: "Audit ingress/egress rules of a security group", + Long: `Print every ingress and egress rule for a security group and flag +risky rules — anything that allows 0.0.0.0/0 (or ::/0) inbound to a sensitive +port (22, 3306, 3389, 5432, 6379, 9200, 27017).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sgID := strings.TrimSpace(args[0]) + if sgID == "" { + return fmt.Errorf("security group id is required") + } + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + return listSGRules(client, sgID) + }, + } + + var kubeconfigPublic bool + kubeconfigCmd := &cobra.Command{ + Use: "kubeconfig [cluster-id]", + Short: "Fetch a kubeconfig for a TKE cluster", + Long: `Fetch a kubeconfig YAML for a TKE cluster and print it on stdout. +Pipe it into a file or kubectl directly: + + clanker tencent kubeconfig cls-xxxxxx --region ap-singapore > ~/.kube/tencent + KUBECONFIG=~/.kube/tencent kubectl get nodes + +Defaults to the private (VPC-internal) endpoint. Use --public for the +externally-routable endpoint when running from outside the cluster's VPC.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + clusterID := strings.TrimSpace(args[0]) + if clusterID == "" { + return fmt.Errorf("cluster id is required") + } + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + return getTKEKubeconfig(client, clusterID, kubeconfigPublic) + }, + } + kubeconfigCmd.Flags().BoolVar(&kubeconfigPublic, "public", false, "Fetch the public (extranet) kubeconfig instead of the VPC-internal one") + + var costMonth string + costCmd := &cobra.Command{ + Use: "cost", + Short: "Tencent Cloud billing — cost commands", + } + costByProductCmd := &cobra.Command{ + Use: "by-product", + Short: "Cost breakdown by Tencent service for a given month", + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + return listBillByProduct(client, costMonth) + }, + } + costByProductCmd.Flags().StringVar(&costMonth, "month", "", "YYYY-MM (default: current month)") + costTopCmd := &cobra.Command{ + Use: "top", + Short: "Top N resources by spend for a given month", + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + topN, _ := cmd.Flags().GetInt("limit") + return listBillResourceTop(client, costMonth, topN) + }, + } + costTopCmd.Flags().StringVar(&costMonth, "month", "", "YYYY-MM (default: current month)") + costTopCmd.Flags().Int("limit", 20, "Number of resources to return (max 200)") + + var voucherStatus string + costVouchersCmd := &cobra.Command{ + Use: "vouchers", + Short: "List vouchers (credits) and voucher spending by owner account", + Long: `List the account's vouchers and a per-owner-UIN breakdown of voucher +spending (nominal − remaining balance). + +By default every voucher is shown. Use --status to filter by Tencent's +voucher-status enum: + unUsed - still usable (this is what "active vouchers" means) + used - fully consumed + delivered - issued but not yet effective + cancel - voided + overdue - expired`, + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + return listVouchers(client, strings.TrimSpace(voucherStatus)) + }, + } + costVouchersCmd.Flags().StringVar(&voucherStatus, "status", "", "Filter by voucher status: unUsed, used, delivered, cancel, overdue (default: all)") + + costVoucherUsageCmd := &cobra.Command{ + Use: "voucher-usage [voucher-id]", + Short: "Show the deduction history for a single voucher", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + creds := ResolveCredentials() + if region != "" { + creds.Region = region + } + client, err := NewClient(creds, viper.GetBool("debug")) + if err != nil { + return err + } + return listVoucherUsage(client, strings.TrimSpace(args[0])) + }, + } + + costCmd.AddCommand(costByProductCmd) + costCmd.AddCommand(costTopCmd) + costCmd.AddCommand(costVouchersCmd) + costCmd.AddCommand(costVoucherUsageCmd) + + tencentCmd.AddCommand(listCmd) + tencentCmd.AddCommand(regionsCmd) + tencentCmd.AddCommand(sgRulesCmd) + tencentCmd.AddCommand(kubeconfigCmd) + tencentCmd.AddCommand(costCmd) + tencentCmd.AddCommand(buildExpiryCmd(®ion)) + return tencentCmd +} diff --git a/internal/tencent/tags.go b/internal/tencent/tags.go new file mode 100644 index 0000000..760796e --- /dev/null +++ b/internal/tencent/tags.go @@ -0,0 +1,77 @@ +package tencent + +import "reflect" + +// extractTags turns any Tencent SDK tag slice into a flat map[string]string. +// +// Tencent's SDK is annoyingly inconsistent about tag-related types across +// services. CVM and VPC use `Tag {Key, Value}`, CBS uses the same, but CDB +// and several DB services emit `ResourceTag {TagKey, TagValue}`. Lighthouse +// uses yet another shape. Rather than writing one converter per service, +// we use reflection: walk the slice, look for a Key/TagKey/TagKeys field +// and a Value/TagValue field on each element, build the map. +// +// Returns nil when the input is nil, empty, or doesn't look like a tag +// slice. Empty maps are normalised to nil so they marshal as `omitempty`. +// +// Usage: +// +// type instSummary struct { +// ... +// Tags map[string]string `json:"tags,omitempty"` +// } +// row := instSummary{ Tags: extractTags(in.Tags), ... } +func extractTags(v any) map[string]string { + if v == nil { + return nil + } + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice { + return nil + } + out := make(map[string]string, rv.Len()) + for i := 0; i < rv.Len(); i++ { + item := rv.Index(i) + if item.Kind() == reflect.Ptr { + if item.IsNil() { + continue + } + item = item.Elem() + } + if item.Kind() != reflect.Struct { + continue + } + key := extractStringField(item, "Key", "TagKey") + if key == "" { + continue + } + val := extractStringField(item, "Value", "TagValue") + out[key] = val + } + if len(out) == 0 { + return nil + } + return out +} + +// extractStringField returns the first non-nil string-valued field found on +// the struct under any of the candidate names. Pointer-to-string fields are +// dereferenced. Returns "" if none match. +func extractStringField(v reflect.Value, names ...string) string { + for _, name := range names { + f := v.FieldByName(name) + if !f.IsValid() { + continue + } + if f.Kind() == reflect.Ptr { + if f.IsNil() { + continue + } + f = f.Elem() + } + if f.Kind() == reflect.String { + return f.String() + } + } + return "" +} diff --git a/internal/tencent/tke.go b/internal/tencent/tke.go new file mode 100644 index 0000000..edb8b07 --- /dev/null +++ b/internal/tencent/tke.go @@ -0,0 +1,137 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + tke "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke/v20180525" +) + +// listTKEClusters prints every TKE cluster across the given regions. +func listTKEClusters(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + cluster *tke.Cluster + } + var rows []row + var warnings []string + + for _, r := range regions { + client, err := newTKEClient(c, r) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init tke client: %v", r, err)) + continue + } + req := tke.NewDescribeClustersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := client.DescribeClusters(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, cl := range resp.Response.Clusters { + rows = append(rows, row{region: r, cluster: cl}) + } + } + + header := fmt.Sprintf("Tencent Kubernetes Engine clusters (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Kubernetes Engine clusters (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No TKE clusters found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tCLUSTER_ID\tNAME\tSTATUS\tK8S_VER\tTYPE\tNODES\tNETWORK\tCREATED") + } else { + fmt.Fprintln(tw, "CLUSTER_ID\tNAME\tSTATUS\tK8S_VER\tTYPE\tNODES\tNETWORK\tCREATED") + } + for _, r := range rows { + cl := r.cluster + network := "-" + if cl.ClusterNetworkSettings != nil && cl.ClusterNetworkSettings.VpcId != nil { + network = *cl.ClusterNetworkSettings.VpcId + } + fields := []string{ + derefString(cl.ClusterId), + derefString(cl.ClusterName), + derefString(cl.ClusterStatus), + derefString(cl.ClusterVersion), + derefString(cl.ClusterType), + fmt.Sprintf("%d", derefUint64(cl.ClusterNodeNum)), + network, + derefString(cl.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +// getTKEKubeconfig fetches a kubeconfig YAML for a single cluster and prints +// it on stdout. When public is true, the externally-routable endpoint is +// returned; otherwise the VPC-internal endpoint. +// +// The cluster's region must match the client's region — kubeconfig fetch is +// region-scoped and the API will 404 if the cluster lives elsewhere. +func getTKEKubeconfig(c *Client, clusterID string, public bool) error { + client, err := newTKEClient(c, c.creds.Region) + if err != nil { + return fmt.Errorf("init tke client: %w", err) + } + req := tke.NewDescribeClusterKubeconfigRequest() + req.ClusterId = &clusterID + req.IsExtranet = &public + resp, err := client.DescribeClusterKubeconfig(req) + if err != nil { + return fmt.Errorf("DescribeClusterKubeconfig: %w", friendlyError(err)) + } + if resp == nil || resp.Response == nil || resp.Response.Kubeconfig == nil { + return fmt.Errorf("empty kubeconfig response for %s", clusterID) + } + fmt.Print(*resp.Response.Kubeconfig) + if !strings.HasSuffix(*resp.Response.Kubeconfig, "\n") { + fmt.Println() + } + return nil +} + +// newDescribeKubeconfigReq builds a TKE DescribeClusterKubeconfig request. +// Extracted so both the CLI command and the HTTP API layer can share the +// construction without exporting an SDK request type from this package. +func newDescribeKubeconfigReq(clusterID string, public bool) *tke.DescribeClusterKubeconfigRequest { + req := tke.NewDescribeClusterKubeconfigRequest() + req.ClusterId = &clusterID + req.IsExtranet = &public + return req +} + +func newTKEClient(c *Client, region string) (*tke.Client, error) { + if strings.TrimSpace(region) == "" { + region = c.creds.Region + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("tke.tencentcloudapi.com") + return tke.NewClient(cred, region, cpf) +} diff --git a/internal/tencent/topology.go b/internal/tencent/topology.go new file mode 100644 index 0000000..a861d65 --- /dev/null +++ b/internal/tencent/topology.go @@ -0,0 +1,409 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + cdb "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb/v20170320" + cvm "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm/v20170312" + postgres "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres/v20170312" + tke "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke/v20180525" + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// Topology is the flat list-of-lists shape returned by /api/v1/tencent/topology. +// The frontend joins resources via the *ID fields. This is the simplest +// structure that supports both region-scoped views (group CVMs by subnet) and +// orphan detection (any CVM whose subnet_id is empty). +type Topology struct { + Region string `json:"region"` + VPCs []TopologyVPC `json:"vpcs"` + Subnets []TopologySubnet `json:"subnets"` + CVMs []TopologyCVM `json:"cvms"` + SecurityGroups []TopologySG `json:"security_groups"` + MySQL []TopologyDB `json:"mysql"` + Postgres []TopologyDB `json:"postgres"` + Clusters []TopologyCluster `json:"clusters"` + Warnings []string `json:"warnings,omitempty"` +} + +type TopologyVPC struct { + ID string `json:"id"` + Name string `json:"name"` + CIDR string `json:"cidr"` + IsDefault bool `json:"is_default"` +} + +type TopologySubnet struct { + ID string `json:"id"` + Name string `json:"name"` + CIDR string `json:"cidr"` + Zone string `json:"zone"` + VpcID string `json:"vpc_id"` +} + +type TopologyCVM struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Type string `json:"type"` + Zone string `json:"zone,omitempty"` + PrivateIP string `json:"private_ip,omitempty"` + PublicIP string `json:"public_ip,omitempty"` + VpcID string `json:"vpc_id,omitempty"` + SubnetID string `json:"subnet_id,omitempty"` + SGIDs []string `json:"sg_ids,omitempty"` +} + +type TopologySG struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsDefault bool `json:"is_default"` +} + +type TopologyDB struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + Engine string `json:"engine,omitempty"` + VpcID string `json:"vpc_id,omitempty"` // empty for classic-network instances + Zone string `json:"zone,omitempty"` +} + +type TopologyCluster struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + Version string `json:"k8s_version,omitempty"` + NodeNum uint64 `json:"node_num,omitempty"` + VpcID string `json:"vpc_id,omitempty"` +} + +// TopologyJSON fetches every resource type concurrently for one region and +// returns the assembled topology as a JSON string. Errors per resource type +// are collected as warnings — a partial topology is more useful than nothing. +func (c *Client) TopologyJSON(ctx context.Context, region string) (string, error) { + if strings.TrimSpace(region) != "" { + c = c.WithRegion(region) + } + t := Topology{Region: c.Region()} + + var wg sync.WaitGroup + var mu sync.Mutex + warn := func(name string, err error) { + mu.Lock() + defer mu.Unlock() + t.Warnings = append(t.Warnings, fmt.Sprintf("%s: %v", name, err)) + } + + wg.Add(7) + + go func() { + defer wg.Done() + v, err := c.topoVPCs() + if err != nil { + warn("vpcs", err) + return + } + mu.Lock() + t.VPCs = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoSubnets() + if err != nil { + warn("subnets", err) + return + } + mu.Lock() + t.Subnets = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoCVMs() + if err != nil { + warn("cvms", err) + return + } + mu.Lock() + t.CVMs = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoSGs() + if err != nil { + warn("security_groups", err) + return + } + mu.Lock() + t.SecurityGroups = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoMySQL() + if err != nil { + warn("mysql", err) + return + } + mu.Lock() + t.MySQL = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoPostgres() + if err != nil { + warn("postgres", err) + return + } + mu.Lock() + t.Postgres = v + mu.Unlock() + }() + go func() { + defer wg.Done() + v, err := c.topoClusters() + if err != nil { + warn("clusters", err) + return + } + mu.Lock() + t.Clusters = v + mu.Unlock() + }() + wg.Wait() + + b, err := json.Marshal(t) + if err != nil { + return "", err + } + return string(b), nil +} + +func (c *Client) topoVPCs() ([]TopologyVPC, error) { + cl, err := c.VPC() + if err != nil { + return nil, err + } + req := vpc.NewDescribeVpcsRequest() + offsetStr, limitStr := "0", "100" + req.Offset = &offsetStr + req.Limit = &limitStr + resp, err := cl.DescribeVpcs(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologyVPC, 0, len(resp.Response.VpcSet)) + for _, v := range resp.Response.VpcSet { + out = append(out, TopologyVPC{ + ID: derefStringRaw(v.VpcId), + Name: derefStringRaw(v.VpcName), + CIDR: derefStringRaw(v.CidrBlock), + IsDefault: derefBool(v.IsDefault), + }) + } + return out, nil +} + +func (c *Client) topoSubnets() ([]TopologySubnet, error) { + cl, err := c.VPC() + if err != nil { + return nil, err + } + resp, err := cl.DescribeSubnets(vpc.NewDescribeSubnetsRequest()) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologySubnet, 0, len(resp.Response.SubnetSet)) + for _, s := range resp.Response.SubnetSet { + out = append(out, TopologySubnet{ + ID: derefStringRaw(s.SubnetId), + Name: derefStringRaw(s.SubnetName), + CIDR: derefStringRaw(s.CidrBlock), + Zone: derefStringRaw(s.Zone), + VpcID: derefStringRaw(s.VpcId), + }) + } + return out, nil +} + +func (c *Client) topoCVMs() ([]TopologyCVM, error) { + cl, err := c.CVM() + if err != nil { + return nil, err + } + req := cvm.NewDescribeInstancesRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cl.DescribeInstances(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologyCVM, 0, len(resp.Response.InstanceSet)) + for _, in := range resp.Response.InstanceSet { + row := TopologyCVM{ + ID: derefStringRaw(in.InstanceId), + Name: derefStringRaw(in.InstanceName), + State: derefStringRaw(in.InstanceState), + Type: derefStringRaw(in.InstanceType), + PrivateIP: firstIP(in.PrivateIpAddresses), + PublicIP: firstIP(in.PublicIpAddresses), + SGIDs: stringSlice(in.SecurityGroupIds), + } + if in.Placement != nil { + row.Zone = derefStringRaw(in.Placement.Zone) + } + if in.VirtualPrivateCloud != nil { + row.VpcID = derefStringRaw(in.VirtualPrivateCloud.VpcId) + row.SubnetID = derefStringRaw(in.VirtualPrivateCloud.SubnetId) + } + out = append(out, row) + } + return out, nil +} + +func (c *Client) topoSGs() ([]TopologySG, error) { + cl, err := c.VPC() + if err != nil { + return nil, err + } + resp, err := cl.DescribeSecurityGroups(vpc.NewDescribeSecurityGroupsRequest()) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologySG, 0, len(resp.Response.SecurityGroupSet)) + for _, g := range resp.Response.SecurityGroupSet { + out = append(out, TopologySG{ + ID: derefStringRaw(g.SecurityGroupId), + Name: derefStringRaw(g.SecurityGroupName), + Description: derefStringRaw(g.SecurityGroupDesc), + IsDefault: derefBool(g.IsDefault), + }) + } + return out, nil +} + +func (c *Client) topoMySQL() ([]TopologyDB, error) { + cl, err := newCDBClient(c, c.creds.Region) + if err != nil { + return nil, err + } + req := cdb.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cl.DescribeDBInstances(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologyDB, 0, len(resp.Response.Items)) + for _, i := range resp.Response.Items { + // CDB returns VPC as an integer ID (UniqVpcId is the string form when present). + vpcID := derefStringRaw(i.UniqVpcId) + out = append(out, TopologyDB{ + ID: derefStringRaw(i.InstanceId), + Name: derefStringRaw(i.InstanceName), + Status: mysqlStatus(i.Status), + Engine: "mysql " + derefStringRaw(i.EngineVersion), + VpcID: vpcID, + Zone: derefStringRaw(i.Zone), + }) + } + return out, nil +} + +func (c *Client) topoPostgres() ([]TopologyDB, error) { + cl, err := newPostgresClient(c, c.creds.Region) + if err != nil { + return nil, err + } + req := postgres.NewDescribeDBInstancesRequest() + var offset, limit uint64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cl.DescribeDBInstances(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologyDB, 0, len(resp.Response.DBInstanceSet)) + for _, i := range resp.Response.DBInstanceSet { + out = append(out, TopologyDB{ + ID: derefStringRaw(i.DBInstanceId), + Name: derefStringRaw(i.DBInstanceName), + Status: derefStringRaw(i.DBInstanceStatus), + Engine: "postgres " + derefStringRaw(i.DBVersion), + VpcID: derefStringRaw(i.VpcId), + Zone: derefStringRaw(i.Zone), + }) + } + return out, nil +} + +func (c *Client) topoClusters() ([]TopologyCluster, error) { + cl, err := newTKEClient(c, c.creds.Region) + if err != nil { + return nil, err + } + req := tke.NewDescribeClustersRequest() + var offset, limit int64 = 0, 100 + req.Offset = &offset + req.Limit = &limit + resp, err := cl.DescribeClusters(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + return nil, nil + } + out := make([]TopologyCluster, 0, len(resp.Response.Clusters)) + for _, k := range resp.Response.Clusters { + row := TopologyCluster{ + ID: derefStringRaw(k.ClusterId), + Name: derefStringRaw(k.ClusterName), + Status: derefStringRaw(k.ClusterStatus), + Version: derefStringRaw(k.ClusterVersion), + NodeNum: derefUint64Raw(k.ClusterNodeNum), + } + if k.ClusterNetworkSettings != nil { + row.VpcID = derefStringRaw(k.ClusterNetworkSettings.VpcId) + } + out = append(out, row) + } + return out, nil +} + +func firstIP(ptrs []*string) string { + for _, p := range ptrs { + if p != nil && strings.TrimSpace(*p) != "" { + return *p + } + } + return "" +} diff --git a/internal/tencent/vouchers.go b/internal/tencent/vouchers.go new file mode 100644 index 0000000..ced230f --- /dev/null +++ b/internal/tencent/vouchers.go @@ -0,0 +1,394 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + billing "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing/v20180709" + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" +) + +// newVoucherClient builds a billing client for the voucher APIs. The cost +// APIs are happy on the global ap-guangzhou endpoint, but DescribeVoucherInfo +// and DescribeVoucherUsageDetails reject every region except the account's +// home region with UnsupportedRegion — so this uses the credential's region. +func newVoucherClient(c *Client) (*billing.Client, error) { + region := strings.TrimSpace(c.creds.Region) + if region == "" { + region = "ap-singapore" + } + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("billing.tencentcloudapi.com") + return billing.NewClient(cred, region, cpf) +} + +// Voucher (代金券) queries — DescribeVoucherInfo + DescribeVoucherUsageDetails. +// +// These live alongside billing.go but answer a different question. The cost +// APIs (DescribeBillSummaryByProduct etc.) tell you what a *month* cost; the +// voucher APIs tell you what credit the account holds and how it was burned. +// +// Two quirks Tencent only applies to the voucher surface: +// +// - Money is fixed-point "micro" units, not decimal strings. Every amount +// (NominalValue, Balance, UsedAmount, ...) is an int64 where 1 yuan = +// 1e8 micro. See microPerYuan / microYuan below. +// - There is no per-record sub-account UIN. DescribeVoucherUsageDetails +// returns deductions per voucher with no UIN field — the owning account +// is a property of the *voucher* (VoucherInfos.OwnerUin), so the +// per-account breakdown is built by grouping vouchers, not usage rows. + +// microPerYuan is the fixed-point scale Tencent's voucher APIs use: 1 unit of +// account currency == 1e8 "micro". DescribeVoucherInfoResponse.Unit confirms +// this ("micro": 1 micro = 10⁻⁸ CNY/USD). +const microPerYuan = 1e8 + +// microYuan converts a micro-denominated int64 pointer to account currency. +func microYuan(v *int64) float64 { + if v == nil { + return 0 + } + return float64(*v) / microPerYuan +} + +// orDash renders an empty string as "-" for table output (the *string +// derefString helper can't be used here — these fields are already strings). +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + +// voucher is one DescribeVoucherInfo entry with micro amounts converted to +// account currency and "spent" derived as nominal − balance. +type voucher struct { + VoucherID string `json:"voucher_id"` + OwnerUin string `json:"owner_uin"` + Status string `json:"status"` + Active bool `json:"active"` // true when still usable (status unUsed) + Nominal float64 `json:"nominal"` + Balance float64 `json:"balance"` + Spent float64 `json:"spent"` + PayMode string `json:"pay_mode,omitempty"` + PayScene string `json:"pay_scene,omitempty"` + BeginTime string `json:"begin_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + Products string `json:"products,omitempty"` + Remark string `json:"remark,omitempty"` +} + +// voucherOwner aggregates one owner UIN's vouchers — the per-account spend +// breakdown shown next to the flat voucher list. +type voucherOwner struct { + OwnerUin string `json:"owner_uin"` + VoucherCount int `json:"voucher_count"` + ActiveCount int `json:"active_count"` + Nominal float64 `json:"nominal"` + Balance float64 `json:"balance"` + Spent float64 `json:"spent"` +} + +// voucherUsage is one DescribeVoucherUsageDetails record: a single deduction +// against a voucher, with the product(s) it paid for flattened to a string. +type voucherUsage struct { + VoucherID string `json:"voucher_id"` + UsedAmount float64 `json:"used_amount"` + UsedTime string `json:"used_time"` + PayMode string `json:"pay_mode,omitempty"` + PayScene string `json:"pay_scene,omitempty"` + Products string `json:"products,omitempty"` + SeqID string `json:"seq_id,omitempty"` +} + +// fetchVouchers pages through DescribeVoucherInfo (max 1000 rows/page) and +// returns every voucher matching the optional status filter — one of +// Tencent's enum strings (unUsed / used / delivered / cancel / overdue) or +// "" for all statuses. +func fetchVouchers(c *Client, status string) ([]voucher, error) { + client, err := newVoucherClient(c) + if err != nil { + return nil, err + } + var out []voucher + var page int64 = 1 + const pageSize int64 = 1000 + for { + req := billing.NewDescribeVoucherInfoRequest() + req.Limit = common.Int64Ptr(pageSize) + req.Offset = common.Int64Ptr(page) // Offset is a 1-based page number + if status != "" { + req.Status = &status + } + resp, err := client.DescribeVoucherInfo(req) + if err != nil { + return nil, friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + for _, v := range resp.Response.VoucherInfos { + if v == nil { + continue + } + st := derefStringRaw(v.Status) + nominal := microYuan(v.NominalValue) + balance := microYuan(v.Balance) + vc := voucher{ + VoucherID: derefStringRaw(v.VoucherId), + OwnerUin: derefStringRaw(v.OwnerUin), + Status: st, + Active: st == "unUsed", + Nominal: nominal, + Balance: balance, + Spent: nominal - balance, + PayMode: derefStringRaw(v.PayMode), + PayScene: derefStringRaw(v.PayScene), + BeginTime: derefStringRaw(v.BeginTime), + EndTime: derefStringRaw(v.EndTime), + Remark: derefStringRaw(v.PolicyRemark), + } + if v.ApplicableProducts != nil { + vc.Products = derefStringRaw(v.ApplicableProducts.GoodsName) + } + out = append(out, vc) + } + total := derefInt64(resp.Response.TotalCount) + if int64(len(out)) >= total || len(resp.Response.VoucherInfos) == 0 { + break + } + page++ + } + return out, nil +} + +// fetchVoucherUsage pages through DescribeVoucherUsageDetails for one voucher +// and returns its deduction history plus the API's reported total. +func fetchVoucherUsage(c *Client, voucherID string) ([]voucherUsage, float64, error) { + client, err := newVoucherClient(c) + if err != nil { + return nil, 0, err + } + var out []voucherUsage + var totalUsed float64 + var page int64 = 1 + const pageSize int64 = 1000 + for { + req := billing.NewDescribeVoucherUsageDetailsRequest() + req.Limit = common.Int64Ptr(pageSize) + req.Offset = common.Int64Ptr(page) + req.VoucherId = &voucherID + resp, err := client.DescribeVoucherUsageDetails(req) + if err != nil { + return nil, 0, friendlyError(err) + } + if resp == nil || resp.Response == nil { + break + } + if resp.Response.TotalUsedAmount != nil { + totalUsed = microYuan(resp.Response.TotalUsedAmount) + } + for _, r := range resp.Response.UsageRecords { + if r == nil { + continue + } + out = append(out, voucherUsage{ + VoucherID: derefStringRaw(r.VoucherId), + UsedAmount: microYuan(r.UsedAmount), + UsedTime: derefStringRaw(r.UsedTime), + PayMode: derefStringRaw(r.PayMode), + PayScene: derefStringRaw(r.PayScene), + Products: usageProducts(r.UsageDetails), + SeqID: derefStringRaw(r.SeqId), + }) + } + total := derefInt64(resp.Response.TotalCount) + if int64(len(out)) >= total || len(resp.Response.UsageRecords) == 0 { + break + } + page++ + } + return out, totalUsed, nil +} + +// usageProducts flattens a usage record's per-product detail rows into a +// deduplicated comma-separated string, preferring the localized name and +// falling back to the English one. +func usageProducts(ds []*billing.UsageDetails) string { + if len(ds) == 0 { + return "" + } + seen := map[string]bool{} + var names []string + for _, d := range ds { + if d == nil { + continue + } + n := derefStringRaw(d.ProductName) + if n == "" { + n = derefStringRaw(d.ProductEnName) + } + if n == "" || seen[n] { + continue + } + seen[n] = true + names = append(names, n) + } + return strings.Join(names, ", ") +} + +// aggregateOwners groups vouchers by owner UIN — the per-account voucher +// spend breakdown. Sorted by spend descending. +func aggregateOwners(vs []voucher) []voucherOwner { + m := map[string]*voucherOwner{} + for _, v := range vs { + o := m[v.OwnerUin] + if o == nil { + o = &voucherOwner{OwnerUin: v.OwnerUin} + m[v.OwnerUin] = o + } + o.VoucherCount++ + if v.Active { + o.ActiveCount++ + } + o.Nominal += v.Nominal + o.Balance += v.Balance + o.Spent += v.Spent + } + out := make([]voucherOwner, 0, len(m)) + for _, o := range m { + out = append(out, *o) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Spent != out[j].Spent { + return out[i].Spent > out[j].Spent + } + return out[i].OwnerUin < out[j].OwnerUin + }) + return out +} + +// VouchersJSON returns the account's vouchers plus a per-owner-UIN spend +// breakdown for the dashboard Cost Explorer. status filters by Tencent's +// voucher-status enum (unUsed/used/delivered/cancel/overdue); "" returns all. +func (c *Client) VouchersJSON(ctx context.Context, status string) (string, error) { + vs, err := fetchVouchers(c, status) + if err != nil { + return "", err + } + var totalNominal, totalBalance, totalSpent float64 + for _, v := range vs { + totalNominal += v.Nominal + totalBalance += v.Balance + totalSpent += v.Spent + } + out := struct { + Status string `json:"status,omitempty"` + Count int `json:"count"` + TotalNominal float64 `json:"total_nominal"` + TotalBalance float64 `json:"total_balance"` + TotalSpent float64 `json:"total_spent"` + Owners []voucherOwner `json:"owners"` + Vouchers []voucher `json:"vouchers"` + }{status, len(vs), totalNominal, totalBalance, totalSpent, aggregateOwners(vs), vs} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// VoucherUsageJSON returns the deduction history for one voucher — the +// drill-down behind a row in the Cost Explorer voucher table. +func (c *Client) VoucherUsageJSON(ctx context.Context, voucherID string) (string, error) { + voucherID = strings.TrimSpace(voucherID) + if voucherID == "" { + return "", fmt.Errorf("voucher_id is required") + } + records, totalUsed, err := fetchVoucherUsage(c, voucherID) + if err != nil { + return "", err + } + out := struct { + VoucherID string `json:"voucher_id"` + Count int `json:"count"` + TotalUsed float64 `json:"total_used"` + Records []voucherUsage `json:"records"` + }{voucherID, len(records), totalUsed, records} + b, err := json.Marshal(out) + if err != nil { + return "", err + } + return string(b), nil +} + +// listVouchers prints the voucher inventory and the per-owner spend breakdown. +func listVouchers(c *Client, status string) error { + vs, err := fetchVouchers(c, status) + if err != nil { + return fmt.Errorf("DescribeVoucherInfo: %w", err) + } + label := status + if label == "" { + label = "all statuses" + } + fmt.Printf("Tencent Cloud Vouchers — %s:\n\n", label) + if len(vs) == 0 { + fmt.Println(" No vouchers found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "VOUCHER_ID\tOWNER_UIN\tSTATUS\tNOMINAL\tBALANCE\tSPENT\tEXPIRES") + for _, v := range vs { + fmt.Fprintf(tw, "%s\t%s\t%s\t%.2f\t%.2f\t%.2f\t%s\n", + orDash(v.VoucherID), orDash(v.OwnerUin), orDash(v.Status), + v.Nominal, v.Balance, v.Spent, orDash(v.EndTime)) + } + if err := tw.Flush(); err != nil { + return err + } + + owners := aggregateOwners(vs) + fmt.Printf("\nVoucher spending by owner account:\n\n") + tw = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "OWNER_UIN\tVOUCHERS\tACTIVE\tNOMINAL\tBALANCE\tSPENT") + for _, o := range owners { + fmt.Fprintf(tw, "%s\t%d\t%d\t%.2f\t%.2f\t%.2f\n", + orDash(o.OwnerUin), o.VoucherCount, o.ActiveCount, o.Nominal, o.Balance, o.Spent) + } + return tw.Flush() +} + +// listVoucherUsage prints the deduction history for a single voucher. +func listVoucherUsage(c *Client, voucherID string) error { + voucherID = strings.TrimSpace(voucherID) + if voucherID == "" { + return fmt.Errorf("voucher id is required") + } + records, totalUsed, err := fetchVoucherUsage(c, voucherID) + if err != nil { + return fmt.Errorf("DescribeVoucherUsageDetails: %w", err) + } + fmt.Printf("Voucher %s — usage history:\n\n", voucherID) + if len(records) == 0 { + fmt.Println(" No usage records for this voucher") + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "USED_TIME\tAMOUNT\tPAY_MODE\tPRODUCTS") + for _, r := range records { + fmt.Fprintf(tw, "%s\t%.2f\t%s\t%s\n", + orDash(r.UsedTime), r.UsedAmount, orDash(r.PayMode), orDash(r.Products)) + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Printf("\nTotal used: %.2f\n", totalUsed) + return nil +} diff --git a/internal/tencent/vpc.go b/internal/tencent/vpc.go new file mode 100644 index 0000000..b05b831 --- /dev/null +++ b/internal/tencent/vpc.go @@ -0,0 +1,391 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + vpc "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc/v20170312" +) + +// sensitivePorts are ports that should never be exposed to 0.0.0.0/0. +// Used by listSGRules to flag risky inbound rules. +var sensitivePorts = map[string]string{ + "22": "SSH", + "3306": "MySQL", + "3389": "RDP", + "5432": "PostgreSQL", + "6379": "Redis", + "9200": "Elasticsearch", + "27017": "MongoDB", +} + +func listVPCs(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + v *vpc.Vpc + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeVpcsRequest() + offsetStr, limitStr := "0", "100" + req.Offset = &offsetStr + req.Limit = &limitStr + resp, err := client.DescribeVpcs(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, v := range resp.Response.VpcSet { + rows = append(rows, row{region: r, v: v}) + } + } + + header := fmt.Sprintf("Tencent Cloud VPCs (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud VPCs (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No VPCs found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tVPC_ID\tNAME\tCIDR\tDEFAULT\tDNS_SERVERS\tCREATED") + } else { + fmt.Fprintln(tw, "VPC_ID\tNAME\tCIDR\tDEFAULT\tDNS_SERVERS\tCREATED") + } + for _, r := range rows { + v := r.v + fields := []string{ + derefString(v.VpcId), + derefString(v.VpcName), + derefString(v.CidrBlock), + fmt.Sprintf("%v", derefBool(v.IsDefault)), + joinIPs(v.DnsServerSet), + derefString(v.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func listSubnets(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + s *vpc.Subnet + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeSubnetsRequest() + resp, err := client.DescribeSubnets(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, s := range resp.Response.SubnetSet { + rows = append(rows, row{region: r, s: s}) + } + } + + header := fmt.Sprintf("Tencent Cloud Subnets (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud Subnets (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No subnets found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tSUBNET_ID\tNAME\tVPC_ID\tCIDR\tZONE\tAVAIL_IPS\tDEFAULT") + } else { + fmt.Fprintln(tw, "SUBNET_ID\tNAME\tVPC_ID\tCIDR\tZONE\tAVAIL_IPS\tDEFAULT") + } + for _, r := range rows { + s := r.s + fields := []string{ + derefString(s.SubnetId), + derefString(s.SubnetName), + derefString(s.VpcId), + derefString(s.CidrBlock), + derefString(s.Zone), + fmt.Sprintf("%d", derefUint64(s.AvailableIpAddressCount)), + fmt.Sprintf("%v", derefBool(s.IsDefault)), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +func listSecurityGroups(c *Client, regions []string) error { + multi := len(regions) > 1 + type row struct { + region string + g *vpc.SecurityGroup + } + var rows []row + var warnings []string + + for _, r := range regions { + rc := c.WithRegion(r) + client, err := rc.VPC() + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: init vpc client: %v", r, err)) + continue + } + req := vpc.NewDescribeSecurityGroupsRequest() + resp, err := client.DescribeSecurityGroups(req) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", r, friendlyError(err))) + continue + } + if resp == nil || resp.Response == nil { + continue + } + for _, g := range resp.Response.SecurityGroupSet { + rows = append(rows, row{region: r, g: g}) + } + } + + header := fmt.Sprintf("Tencent Cloud Security Groups (region=%s)", c.Region()) + if multi { + header = fmt.Sprintf("Tencent Cloud Security Groups (regions=%d)", len(regions)) + } + fmt.Printf("%s:\n\n", header) + if len(rows) == 0 { + fmt.Println(" No security groups found") + printWarnings(warnings) + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + if multi { + fmt.Fprintln(tw, "REGION\tSG_ID\tNAME\tDESCRIPTION\tDEFAULT\tCREATED") + } else { + fmt.Fprintln(tw, "SG_ID\tNAME\tDESCRIPTION\tDEFAULT\tCREATED") + } + for _, r := range rows { + g := r.g + fields := []string{ + derefString(g.SecurityGroupId), + derefString(g.SecurityGroupName), + derefString(g.SecurityGroupDesc), + fmt.Sprintf("%v", derefBool(g.IsDefault)), + derefString(g.CreatedTime), + } + if multi { + fmt.Fprintln(tw, r.region+"\t"+strings.Join(fields, "\t")) + } else { + fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + } + if err := tw.Flush(); err != nil { + return err + } + printWarnings(warnings) + return nil +} + +// listSGRules prints ingress + egress rules for a single security group and +// flags rules that expose sensitive ports (22, 3306, 5432, 6379, 27017, etc) +// to the public internet (0.0.0.0/0 or ::/0). +func listSGRules(c *Client, sgID string) error { + client, err := c.VPC() + if err != nil { + return fmt.Errorf("init vpc client: %w", err) + } + req := vpc.NewDescribeSecurityGroupPoliciesRequest() + req.SecurityGroupId = &sgID + resp, err := client.DescribeSecurityGroupPolicies(req) + if err != nil { + return fmt.Errorf("DescribeSecurityGroupPolicies: %w", friendlyError(err)) + } + if resp == nil || resp.Response == nil || resp.Response.SecurityGroupPolicySet == nil { + fmt.Printf("Security Group %s (region=%s): no policies returned\n", sgID, c.Region()) + return nil + } + policies := resp.Response.SecurityGroupPolicySet + + fmt.Printf("Security Group %s — rule audit (region=%s):\n\n", sgID, c.Region()) + + risky := 0 + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DIRECTION\tIDX\tPROTOCOL\tPORT\tSOURCE/DEST\tACTION\tDESCRIPTION\tRISK") + + for _, p := range policies.Ingress { + risk := classifySGRule(p, true) + if risk != "" { + risky++ + } + fmt.Fprintln(tw, sgRuleRow("INGRESS", p, risk)) + } + for _, p := range policies.Egress { + risk := classifySGRule(p, false) + fmt.Fprintln(tw, sgRuleRow("EGRESS", p, risk)) + } + if err := tw.Flush(); err != nil { + return err + } + + fmt.Println() + if risky > 0 { + fmt.Printf("⚠️ %d risky ingress rule(s) detected — sensitive port exposed to 0.0.0.0/0\n", risky) + } else { + fmt.Println("✓ No public exposure of sensitive ports detected in this security group") + } + return nil +} + +func sgRuleRow(dir string, p *vpc.SecurityGroupPolicy, risk string) string { + idx := "" + if p.PolicyIndex != nil { + idx = fmt.Sprintf("%d", *p.PolicyIndex) + } + source := derefString(p.CidrBlock) + if source == "-" { + source = derefString(p.Ipv6CidrBlock) + } + if source == "-" && p.SecurityGroupId != nil { + source = "sg:" + *p.SecurityGroupId + } + if risk == "" { + risk = "-" + } + return strings.Join([]string{ + dir, + idx, + derefString(p.Protocol), + derefString(p.Port), + source, + derefString(p.Action), + derefString(p.PolicyDescription), + risk, + }, "\t") +} + +// classifySGRule returns a non-empty risk label when the rule allows a public +// CIDR (0.0.0.0/0 or ::/0) inbound to a sensitive port. +func classifySGRule(p *vpc.SecurityGroupPolicy, ingress bool) string { + if !ingress || p == nil { + return "" + } + if p.Action == nil || !strings.EqualFold(*p.Action, "ACCEPT") { + return "" + } + cidr := strings.TrimSpace(derefString(p.CidrBlock)) + cidr6 := strings.TrimSpace(derefString(p.Ipv6CidrBlock)) + publicAll := cidr == "0.0.0.0/0" || cidr6 == "::/0" + if !publicAll { + return "" + } + port := strings.TrimSpace(derefString(p.Port)) + proto := strings.ToUpper(strings.TrimSpace(derefString(p.Protocol))) + + // "ALL" port or "-1" → everything exposed + if port == "ALL" || port == "-1" || port == "*" || (proto == "ALL" && port == "-") { + return "PUBLIC-ALL-PORTS" + } + // Check sensitive ports — port can be "22", "22,80", "22-100" + for sp, name := range sensitivePorts { + if portMatches(port, sp) { + return "PUBLIC-" + name + } + } + return "" +} + +// portMatches checks whether port spec (e.g. "22", "22,80", "20-30") covers +// the target single port string. +func portMatches(spec, target string) bool { + spec = strings.TrimSpace(spec) + if spec == "" { + return false + } + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == target { + return true + } + if strings.Contains(part, "-") { + bounds := strings.SplitN(part, "-", 2) + if len(bounds) == 2 { + var lo, hi, t int + if _, err := fmt.Sscanf(bounds[0], "%d", &lo); err != nil { + continue + } + if _, err := fmt.Sscanf(bounds[1], "%d", &hi); err != nil { + continue + } + if _, err := fmt.Sscanf(target, "%d", &t); err != nil { + continue + } + if t >= lo && t <= hi { + return true + } + } + } + } + return false +} + +func derefBool(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func derefUint64(v *uint64) uint64 { + if v == nil { + return 0 + } + return *v +} diff --git a/internal/tencent/waf.go b/internal/tencent/waf.go new file mode 100644 index 0000000..7e72928 --- /dev/null +++ b/internal/tencent/waf.go @@ -0,0 +1,95 @@ +package tencent + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" + waf "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf/v20180125" +) + +// listWAFHosts prints every WAF-protected host. WAF is account-global. +// DescribeHosts without filter args returns the host list directly. +func listWAFHosts(c *Client) error { + client, err := newWAFClient(c) + if err != nil { + return fmt.Errorf("init waf client: %w", err) + } + resp, err := client.DescribeHosts(waf.NewDescribeHostsRequest()) + if err != nil { + return fmt.Errorf("DescribeHosts: %w", friendlyError(err)) + } + + fmt.Println("Tencent Cloud WAF Protected Hosts:") + fmt.Println() + if resp == nil || resp.Response == nil || resp.Response.HostList == nil || len(resp.Response.HostList) == 0 { + fmt.Println(" No WAF-protected hosts found") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DOMAIN_ID\tDOMAIN\tMAIN_DOMAIN\tMODE\tSTATUS") + for _, h := range resp.Response.HostList { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + derefString(h.DomainId), + derefString(h.Domain), + derefString(h.MainDomain), + wafMode(h.Mode), + wafStatus(h.Status), + ) + } + return tw.Flush() +} + +func newWAFClient(c *Client) (*waf.Client, error) { + cred := common.NewCredential(c.creds.SecretID, c.creds.SecretKey) + cpf := newClientProfile("waf.tencentcloudapi.com") + return waf.NewClient(cred, "ap-guangzhou", cpf) +} + +func wafMode(p *uint64) string { + if p == nil { + return "-" + } + if *p == 0 { + return "OBSERVE" + } + if *p == 1 { + return "BLOCK" + } + return fmt.Sprintf("MODE-%d", *p) +} + +func wafStatus(p *uint64) string { + if p == nil { + return "-" + } + if *p == 0 { + return "UNBOUND" + } + if *p == 1 { + return "BOUND" + } + return fmt.Sprintf("STATE-%d", *p) +} + +// listWAFHostNames returns the set of protected hostnames for the audit. +func listWAFHostNames(c *Client) map[string]bool { + out := map[string]bool{} + client, err := newWAFClient(c) + if err != nil { + return out + } + resp, err := client.DescribeHosts(waf.NewDescribeHostsRequest()) + if err != nil || resp == nil || resp.Response == nil || resp.Response.HostList == nil { + return out + } + for _, h := range resp.Response.HostList { + if d := strings.TrimSpace(derefString(h.Domain)); d != "" && d != "-" { + out[d] = true + } + } + return out +}