212 lines
6.6 KiB
Go
212 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
|
)
|
|
|
|
type authSecret struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type targetSecret struct {
|
|
PlatformID string `json:"platformId"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type config struct {
|
|
BaseURL string
|
|
AuthSecretFile string
|
|
TargetSecret string
|
|
TargetAddress string
|
|
StateFile string
|
|
HealthAddress string
|
|
HTTPTimeout time.Duration
|
|
TargetTimeout time.Duration
|
|
OCRImage string
|
|
OCRTimeout time.Duration
|
|
LoginAttempts int
|
|
LoginRetryDelay time.Duration
|
|
Service feichibridge.ServiceConfig
|
|
}
|
|
|
|
func main() {
|
|
logger := observability.NewLogger("feichi-bridge")
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
logger.Error("load configuration failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
auth, err := readJSONSecret[authSecret](cfg.AuthSecretFile)
|
|
if err != nil {
|
|
logger.Error("read Feichi API secret failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
credentials, err := readJSONSecret[targetSecret](cfg.TargetSecret)
|
|
if err != nil {
|
|
logger.Error("read GB/T 32960 target secret failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
source, err := feichibridge.NewAuthenticatedAPIClient(
|
|
cfg.BaseURL,
|
|
feichibridge.LoginCredentials{
|
|
Username: auth.Username, Password: auth.Password,
|
|
MaxAttempts: cfg.LoginAttempts, RetryDelay: cfg.LoginRetryDelay,
|
|
},
|
|
feichibridge.DockerCaptchaSolver{Image: cfg.OCRImage, Timeout: cfg.OCRTimeout},
|
|
cfg.HTTPTimeout,
|
|
)
|
|
if err != nil {
|
|
logger.Error("build Feichi API client failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
state, err := feichibridge.OpenStateStore(cfg.StateFile)
|
|
if err != nil {
|
|
logger.Error("open bridge state failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
target, err := feichibridge.NewTarget(feichibridge.TargetConfig{
|
|
Address: cfg.TargetAddress, PlatformID: credentials.PlatformID,
|
|
Username: credentials.Username, Password: credentials.Password,
|
|
Timeout: cfg.TargetTimeout,
|
|
}, state)
|
|
if err != nil {
|
|
logger.Error("build GB/T 32960 target failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
registry := metrics.NewRegistry()
|
|
service, err := feichibridge.NewService(cfg.Service, source, target, state, logger, registry)
|
|
if err != nil {
|
|
logger.Error("build bridge service failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
health.Start(ctx, logger, health.NewServer(cfg.HealthAddress, "feichi-bridge", []health.Check{
|
|
{Name: "bridge", Check: service.Ready},
|
|
}, registry))
|
|
logger.Info("Feichi bridge starting",
|
|
"base_url", cfg.BaseURL,
|
|
"target_address", cfg.TargetAddress,
|
|
"poll_interval", cfg.Service.PollInterval,
|
|
"backfill_enabled", cfg.Service.BackfillEnabled,
|
|
)
|
|
if strings.HasPrefix(strings.ToLower(cfg.BaseURL), "http://") {
|
|
logger.Warn("Feichi source uses clear-text HTTP; deploy only through a controlled egress path")
|
|
}
|
|
if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
logger.Error("Feichi bridge stopped unexpectedly", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
logger.Info("Feichi bridge stopped")
|
|
}
|
|
|
|
func loadConfig() (config, error) {
|
|
cfg := config{
|
|
BaseURL: strings.TrimSpace(os.Getenv("FEICHI_BASE_URL")),
|
|
AuthSecretFile: env("FEICHI_AUTH_SECRET_FILE", strings.TrimSpace(os.Getenv("FEICHI_AUTH_HEADERS_FILE"))),
|
|
TargetSecret: strings.TrimSpace(os.Getenv("FEICHI_TARGET_SECRET_FILE")),
|
|
TargetAddress: env("FEICHI_TARGET_ADDR", "127.0.0.1:32960"),
|
|
StateFile: env("FEICHI_STATE_FILE", "/var/lib/lingniu-feichi-bridge/state.json"),
|
|
HealthAddress: env("HEALTH_ADDR", "127.0.0.1:20219"),
|
|
HTTPTimeout: seconds("FEICHI_HTTP_TIMEOUT_SECONDS", 15),
|
|
TargetTimeout: seconds("FEICHI_TARGET_TIMEOUT_SECONDS", 10),
|
|
OCRImage: env("FEICHI_OCR_IMAGE", "lingniu/feichi-captcha-ocr:1.0.0"),
|
|
OCRTimeout: seconds("FEICHI_OCR_TIMEOUT_SECONDS", 20),
|
|
LoginAttempts: envInt("FEICHI_LOGIN_MAX_ATTEMPTS", 20),
|
|
LoginRetryDelay: seconds("FEICHI_LOGIN_RETRY_SECONDS", 1),
|
|
Service: feichibridge.ServiceConfig{
|
|
PollInterval: seconds("FEICHI_POLL_INTERVAL_SECONDS", 10),
|
|
DiscoveryInterval: seconds("FEICHI_DISCOVERY_INTERVAL_SECONDS", 300),
|
|
BackfillInterval: seconds("FEICHI_BACKFILL_INTERVAL_SECONDS", 3600),
|
|
BackfillLookback: seconds("FEICHI_BACKFILL_LOOKBACK_SECONDS", 3600),
|
|
BackfillWindow: seconds("FEICHI_BACKFILL_WINDOW_SECONDS", 1200),
|
|
BackfillSafetyLag: seconds("FEICHI_BACKFILL_SAFETY_SECONDS", 30),
|
|
SourceStaleAfter: seconds("FEICHI_SOURCE_STALE_SECONDS", 120),
|
|
FetchConcurrency: envInt("FEICHI_FETCH_CONCURRENCY", 4),
|
|
BackfillEnabled: envBool("FEICHI_BACKFILL_ENABLED", true),
|
|
StaleReissueEnabled: envBool("FEICHI_STALE_REISSUE_ENABLED", true),
|
|
},
|
|
}
|
|
var missing []string
|
|
if cfg.BaseURL == "" {
|
|
missing = append(missing, "FEICHI_BASE_URL")
|
|
}
|
|
if cfg.AuthSecretFile == "" {
|
|
missing = append(missing, "FEICHI_AUTH_SECRET_FILE")
|
|
}
|
|
if cfg.TargetSecret == "" {
|
|
missing = append(missing, "FEICHI_TARGET_SECRET_FILE")
|
|
}
|
|
if len(missing) > 0 {
|
|
return config{}, fmt.Errorf("required configuration missing: %s", strings.Join(missing, ", "))
|
|
}
|
|
if cfg.Service.BackfillWindow > 24*time.Hour {
|
|
return config{}, errors.New("FEICHI_BACKFILL_WINDOW_SECONDS must not exceed 86400")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func readJSONSecret[T any](path string) (T, error) {
|
|
var value T
|
|
encoded, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return value, err
|
|
}
|
|
if err := json.Unmarshal(encoded, &value); err != nil {
|
|
return value, fmt.Errorf("decode %s: %w", path, err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func env(name, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envInt(name string, fallback int) int {
|
|
value := strings.TrimSpace(os.Getenv(name))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func envBool(name string, fallback bool) bool {
|
|
value := strings.TrimSpace(os.Getenv(name))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func seconds(name string, fallback int) time.Duration {
|
|
return time.Duration(envInt(name, fallback)) * time.Second
|
|
}
|