feat(backend): 挂载 /v1 API + 实现 nodes/connect 端到端
- 新增 internal/dpcred 包,统一 DeriveHy2Password + DefaultFlow agentd 与 HTTP connect handler 共享同一实现 - 新增迁移 000011:nodes 表拆分 reality_prk 私钥 / reality_pbk 公钥 reality_short_id;修正 handler_grpc.go 使用私钥字段 - 新增迁移 000012:connect_credentials 持久化凭证 实现 CredentialsForNode 修复 agent 重连 resync 原先返回空的桩 - 扩展 NodeStore 接口:ListUp / EntitlementForUser / PersistCredential / DeleteCredential;同步 grpc_test.go mock - 新增 httpapi/nodes.go:GET /nodes、POST /nodes/id/connect Hub.Push + PersistCredential + 渲染完整 sing-box client 配置 JSON POST /nodes/id/disconnect - 新增 httpapi/account.go:GET /me、GET /plans、GET /notices - 新增 httpapi/clientconfig.go:BuildClientConfig 服务端渲染 - 重写 cmd/server/main.go:手写 chi public/protected 分组 nodes.Service/Hub 在 main 构造并共享;SMTPMailer/LogMailer go build ./... && go vet ./... && go test ./... 全部通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+237
-104
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
@@ -10,122 +11,254 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
"github.com/wangjia/pangolin/server/api/gen"
|
||||
"github.com/wangjia/pangolin/server/internal/admin"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
"github.com/wangjia/pangolin/server/internal/auth"
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
"github.com/wangjia/pangolin/server/internal/db"
|
||||
"github.com/wangjia/pangolin/server/internal/devices"
|
||||
"github.com/wangjia/pangolin/server/internal/httpapi"
|
||||
"github.com/wangjia/pangolin/server/internal/mtls"
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
|
||||
"github.com/wangjia/pangolin/server/internal/redisutil"
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||||
"github.com/wangjia/pangolin/server/internal/usage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
|
||||
listenAddr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
|
||||
flag.Parse()
|
||||
|
||||
if *addr == "" {
|
||||
if *listenAddr == "" {
|
||||
if v := os.Getenv("ADDR"); v != "" {
|
||||
*addr = v
|
||||
*listenAddr = v
|
||||
} else {
|
||||
*addr = ":8080"
|
||||
*listenAddr = ":8080"
|
||||
}
|
||||
}
|
||||
|
||||
// Admin backend (separate internal listener). Started only when explicitly
|
||||
// configured so the public API can run on its own. See internal/admin.
|
||||
// Admin backend (separate internal listener, optional).
|
||||
startAdminIfConfigured()
|
||||
|
||||
// ─── HTTP server ──────────────────────────────────────────────────────────
|
||||
// ─── Shared: Redis ────────────────────────────────────────────────────────
|
||||
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
|
||||
rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
|
||||
if err != nil {
|
||||
log.Fatalf("redis connect: %v", err)
|
||||
}
|
||||
|
||||
// ─── Shared: DB ───────────────────────────────────────────────────────────
|
||||
dbDSN := os.Getenv("DB_DSN")
|
||||
var sqlDB *sql.DB
|
||||
if dbDSN != "" {
|
||||
sqlDB, err = db.Open(dbDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("db open: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared: nodes.Service (Hub shared by HTTP connect + gRPC agent) ──────
|
||||
// Constructed here so both sides use the same Hub instance.
|
||||
var nodeSvc *nodes.Service
|
||||
if sqlDB != nil {
|
||||
nodeStore := nodes.NewSQLNodeStore(sqlDB)
|
||||
|
||||
grpcAddr := os.Getenv("GRPC_ADDR")
|
||||
caKeyPath := os.Getenv("CA_KEY_PATH")
|
||||
caCertPath := os.Getenv("CA_CERT_PATH")
|
||||
grpcCertPath := os.Getenv("GRPC_CERT_PATH")
|
||||
grpcKeyPath := os.Getenv("GRPC_KEY_PATH")
|
||||
|
||||
if grpcAddr != "" && caKeyPath != "" && caCertPath != "" &&
|
||||
grpcCertPath != "" && grpcKeyPath != "" {
|
||||
|
||||
ca, err := mtls.NewCA(mtls.CAConfig{KeyPath: caKeyPath, CertPath: caCertPath})
|
||||
if err != nil {
|
||||
log.Fatalf("load node CA: %v", err)
|
||||
}
|
||||
tokens := mtls.NewBootstrapTokenManager(rdb)
|
||||
crl := mtls.NewCRL(rdb, nil)
|
||||
nodeSvc = nodes.NewService(ca, tokens, rdb, nodeStore)
|
||||
nodeSvc.Hub().Start(context.Background())
|
||||
|
||||
go startGRPCWithService(nodeSvc, grpcAddr, grpcCertPath, grpcKeyPath, ca, crl)
|
||||
|
||||
} else {
|
||||
if grpcAddr != "" {
|
||||
slog.Warn("grpc agent server disabled: one or more required env vars missing",
|
||||
"CA_KEY_PATH_set", caKeyPath != "",
|
||||
"CA_CERT_PATH_set", caCertPath != "",
|
||||
"GRPC_CERT_PATH_set", grpcCertPath != "",
|
||||
"GRPC_KEY_PATH_set", grpcKeyPath != "")
|
||||
}
|
||||
// Even without gRPC, build a Hub-only nodeSvc for local/test use.
|
||||
// nil CA means Enroll will panic if called — acceptable when gRPC is off.
|
||||
hub := nodes.NewHub(rdb)
|
||||
hub.Start(context.Background())
|
||||
log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP router ──────────────────────────────────────────────────────────
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(apierr.Middleware)
|
||||
|
||||
// Health check — outside the versioned API.
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// --- Probe ingest route (optional) ---
|
||||
// Requires:
|
||||
// PROBE_SECRETS – JSON object mapping probeId → HMAC secret, e.g.
|
||||
// '{"probe-sg-01":"s3cr3t1","probe-jp-01":"s3cr3t2"}'
|
||||
// REDIS_ADDR – Redis address, default 127.0.0.1:6379
|
||||
// REDIS_PASSWORD – Redis password (optional)
|
||||
//
|
||||
// If PROBE_SECRETS is empty the route is not registered and the server
|
||||
// starts normally without the probe ingest endpoint.
|
||||
probeSecretsJSON := os.Getenv("PROBE_SECRETS")
|
||||
if probeSecretsJSON != "" {
|
||||
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
|
||||
rdb, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
|
||||
// Optional probe ingest route.
|
||||
if probeJSON := os.Getenv("PROBE_SECRETS"); probeJSON != "" {
|
||||
pRDB, err := redisutil.New(redisAddr, os.Getenv("REDIS_PASSWORD"), 0)
|
||||
if err != nil {
|
||||
log.Printf("probe: redis connect failed (%v) – probe route disabled", err)
|
||||
} else {
|
||||
var secretMap map[string]string
|
||||
if err := json.Unmarshal([]byte(probeSecretsJSON), &secretMap); err != nil {
|
||||
if err := json.Unmarshal([]byte(probeJSON), &secretMap); err != nil {
|
||||
log.Fatalf("probe: invalid PROBE_SECRETS JSON: %v", err)
|
||||
}
|
||||
reg := probe.NewMapRegistry(secretMap)
|
||||
st := probe.NewStore(rdb)
|
||||
h := probe.NewIngestHandler(reg, st)
|
||||
r.Post("/probe/report", h.ServeHTTP)
|
||||
st := probe.NewStore(pRDB)
|
||||
r.Post("/probe/report", probe.NewIngestHandler(reg, st).ServeHTTP)
|
||||
log.Printf("probe ingest route registered (%d probe(s))", len(secretMap))
|
||||
}
|
||||
}
|
||||
|
||||
// Mount all /v1/... routes. HandlerFromMuxWithBaseURL registers every route
|
||||
// from the OpenAPI spec onto the provided chi router with the given prefix,
|
||||
// so the router itself is the http.Handler we serve.
|
||||
gen.HandlerFromMuxWithBaseURL(&httpapi.UnimplementedServer{}, r, "/v1")
|
||||
|
||||
// ─── gRPC agent server (optional) ────────────────────────────────────────
|
||||
// All five env vars must be set to activate the gRPC listener.
|
||||
|
||||
grpcAddr := os.Getenv("GRPC_ADDR")
|
||||
caKeyPath := os.Getenv("CA_KEY_PATH")
|
||||
caCertPath := os.Getenv("CA_CERT_PATH")
|
||||
grpcCertPath := os.Getenv("GRPC_CERT_PATH")
|
||||
grpcKeyPath := os.Getenv("GRPC_KEY_PATH")
|
||||
dbDSN := os.Getenv("DB_DSN")
|
||||
|
||||
if grpcAddr != "" && caKeyPath != "" && caCertPath != "" &&
|
||||
grpcCertPath != "" && grpcKeyPath != "" && dbDSN != "" {
|
||||
redisAddr := getenvDefault("REDIS_ADDR", "127.0.0.1:6379")
|
||||
go startGRPC(grpcAddr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
|
||||
redisAddr, os.Getenv("REDIS_PASSWORD"), dbDSN)
|
||||
} else if grpcAddr != "" {
|
||||
slog.Warn("grpc server disabled: one or more required env vars are missing",
|
||||
"GRPC_ADDR", grpcAddr,
|
||||
"CA_KEY_PATH_set", caKeyPath != "",
|
||||
"CA_CERT_PATH_set", caCertPath != "",
|
||||
"GRPC_CERT_PATH_set", grpcCertPath != "",
|
||||
"GRPC_KEY_PATH_set", grpcKeyPath != "",
|
||||
"DB_DSN_set", dbDSN != "")
|
||||
// ─── /v1 routes (requires DB) ────────────────────────────────────────────
|
||||
if sqlDB != nil {
|
||||
mountV1(r, sqlDB, rdb, nodeSvc)
|
||||
} else {
|
||||
log.Printf("DB_DSN not set; /v1 routes disabled")
|
||||
}
|
||||
|
||||
// ─── Start HTTP server (blocking) ────────────────────────────────────────
|
||||
|
||||
log.Printf("pangolin server listening on %s", *addr)
|
||||
if err := http.ListenAndServe(*addr, r); err != nil {
|
||||
log.Printf("pangolin server listening on %s", *listenAddr)
|
||||
if err := http.ListenAndServe(*listenAddr, r); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startAdminIfConfigured launches the admin listener in a background goroutine
|
||||
// when ADMIN_SECRET_KEY and DB_DSN are present. The admin port binds an
|
||||
// internal address only (enforced by admin.FromEnv) and is fronted by an IP
|
||||
// allowlist + two-factor login.
|
||||
// mountV1 wires all /v1 routes onto r.
|
||||
func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Service) {
|
||||
// ── JWT TokenManager ──────────────────────────────────────────────────────
|
||||
jwtPrivPath := os.Getenv("JWT_PRIVATE_KEY_PATH")
|
||||
jwtKID := os.Getenv("JWT_KEY_ID")
|
||||
var tm *auth.TokenManager
|
||||
if jwtPrivPath != "" && jwtKID != "" {
|
||||
tc, err := auth.LoadTokenConfig(jwtPrivPath, jwtKID, parseKeyMap(os.Getenv("JWT_PUBLIC_KEYS")))
|
||||
if err != nil {
|
||||
log.Fatalf("JWT keys: %v", err)
|
||||
}
|
||||
tm, err = auth.NewTokenManager(rdb, tc)
|
||||
if err != nil {
|
||||
log.Fatalf("JWT token manager: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("JWT not configured — /v1 protected routes will be unavailable")
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
var authHandler *auth.Handler
|
||||
if tm != nil {
|
||||
var mailer auth.Mailer
|
||||
if smtpHost := os.Getenv("SMTP_HOST"); smtpHost != "" {
|
||||
mailer = auth.NewSMTPMailer(auth.SMTPConfig{
|
||||
Host: smtpHost,
|
||||
Port: intEnvDefault("SMTP_PORT", 587),
|
||||
Username: os.Getenv("SMTP_USERNAME"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: getenvDefault("SMTP_FROM", "no-reply@pangolin.app"),
|
||||
})
|
||||
} else {
|
||||
mailer = auth.NewLogMailer(nil) // dev: code printed to log
|
||||
}
|
||||
rl := auth.NewRateLimiter(rdb, nil)
|
||||
authStore := auth.NewSQLStore(sqlDB)
|
||||
authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil)
|
||||
authHandler = auth.NewHandler(authSvc)
|
||||
}
|
||||
|
||||
// ── Codes ────────────────────────────────────────────────────────────────
|
||||
codesStore := codes.NewStore(sqlDB)
|
||||
codesSvc := codes.NewService(codesStore, rdb, 5, time.Hour)
|
||||
redeemHandler := codes.NewRedeemHandler(codesSvc)
|
||||
webhookHandler := codes.NewWebhookHandler(codesStore, rdb,
|
||||
os.Getenv("WEBHOOK_SECRET"), 5*time.Minute, 15*time.Minute)
|
||||
|
||||
// ── Devices ───────────────────────────────────────────────────────────────
|
||||
devicesStore := devices.NewStore(sqlDB)
|
||||
devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP
|
||||
devicesHandler := devices.NewHandler(devicesSvc)
|
||||
|
||||
// ── Usage ─────────────────────────────────────────────────────────────────
|
||||
usageStore := usage.NewStore(sqlDB)
|
||||
usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour)
|
||||
usageHandler := usage.NewUsageHandler(usageSvc)
|
||||
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
|
||||
|
||||
// ── Account / Plans / Notices ─────────────────────────────────────────────
|
||||
accountAPI := httpapi.NewAccountAPI(sqlDB)
|
||||
|
||||
// ── Nodes + Connect ───────────────────────────────────────────────────────
|
||||
var nodeAPI *httpapi.NodeAPI
|
||||
if nodeSvc != nil {
|
||||
nodeAPI = httpapi.NewNodeAPI(nodeSvc.Store(), nodeSvc.Hub(), os.Getenv("NODE_DERIVE_KEY"))
|
||||
}
|
||||
|
||||
// ─── Mount under /v1 ─────────────────────────────────────────────────────
|
||||
r.Route("/v1", func(v1 chi.Router) {
|
||||
// Public (no auth): auth endpoints.
|
||||
if authHandler != nil {
|
||||
v1.Post("/auth/code", authHandler.SendCode)
|
||||
v1.Post("/auth/register", authHandler.Register)
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
}
|
||||
|
||||
// Webhook: HMAC-authenticated, no JWT.
|
||||
v1.Post("/webhook/store/codes", webhookHandler.ServeHTTP)
|
||||
|
||||
// Protected: all routes that require a valid Bearer JWT.
|
||||
if tm != nil {
|
||||
v1.Group(func(protected chi.Router) {
|
||||
protected.Use(auth.RequireAuth(tm))
|
||||
|
||||
protected.Get("/me", accountAPI.GetMe)
|
||||
protected.Route("/me", func(me chi.Router) {
|
||||
devicesHandler.RegisterRoutes(me)
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
|
||||
protected.Get("/plans", accountAPI.ListPlans)
|
||||
protected.Get("/notices", accountAPI.ListNotices)
|
||||
|
||||
if nodeAPI != nil {
|
||||
protected.Get("/nodes", nodeAPI.ListNodes)
|
||||
protected.Post("/nodes/{id}/connect", nodeAPI.ConnectNode)
|
||||
protected.Post("/nodes/{id}/disconnect", nodeAPI.DisconnectNode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// startAdminIfConfigured launches the admin listener when ADMIN_SECRET_KEY and
|
||||
// DB_DSN are both present.
|
||||
func startAdminIfConfigured() {
|
||||
if os.Getenv("ADMIN_SECRET_KEY") == "" || os.Getenv("DB_DSN") == "" {
|
||||
log.Printf("admin backend disabled (set DB_DSN and ADMIN_SECRET_KEY to enable)")
|
||||
@@ -145,13 +278,11 @@ func startAdminIfConfigured() {
|
||||
if err != nil {
|
||||
log.Fatalf("admin redis: %v", err)
|
||||
}
|
||||
|
||||
svc := admin.BuildServices(database, rdb, 5, cfg.LoginLockDuration)
|
||||
handler, err := admin.NewHandler(cfg, database, rdb, svc, log.Default())
|
||||
if err != nil {
|
||||
log.Fatalf("admin handler: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("admin backend listening on %s (internal only)", cfg.Listen)
|
||||
if err := http.ListenAndServe(cfg.Listen, handler); err != nil {
|
||||
@@ -160,43 +291,14 @@ func startAdminIfConfigured() {
|
||||
}()
|
||||
}
|
||||
|
||||
// startGRPC initialises and starts the mTLS gRPC agent server.
|
||||
// Must be called in a goroutine. Calls log.Fatalf on unrecoverable errors.
|
||||
func startGRPC(
|
||||
addr, caKeyPath, caCertPath, grpcCertPath, grpcKeyPath,
|
||||
redisAddr, redisPassword, dbDSN string,
|
||||
// startGRPCWithService starts the mTLS gRPC agent server using the shared
|
||||
// nodes.Service so the HTTP connect handler and the gRPC server share one Hub.
|
||||
func startGRPCWithService(
|
||||
svc *nodes.Service,
|
||||
addr, grpcCertPath, grpcKeyPath string,
|
||||
ca *mtls.CA,
|
||||
crl *mtls.CRL,
|
||||
) {
|
||||
// Pangolin Node CA (signs + verifies node client certs).
|
||||
ca, err := mtls.NewCA(mtls.CAConfig{
|
||||
KeyPath: caKeyPath,
|
||||
CertPath: caCertPath,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("grpc: load CA: %v", err)
|
||||
}
|
||||
|
||||
// Redis (hub command queue, load cache, CRL, bootstrap tokens).
|
||||
rdb, err := redisutil.New(redisAddr, redisPassword, 0)
|
||||
if err != nil {
|
||||
log.Fatalf("grpc: redis connect: %v", err)
|
||||
}
|
||||
|
||||
// CRL + one-time bootstrap token manager.
|
||||
crl := mtls.NewCRL(rdb, nil)
|
||||
tokens := mtls.NewBootstrapTokenManager(rdb)
|
||||
|
||||
// MySQL for node catalogue and usage accumulation.
|
||||
sqlDB, err := db.Open(dbDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("grpc: db open: %v", err)
|
||||
}
|
||||
|
||||
// Nodes service (wires Hub, LoadCache, Handler).
|
||||
store := nodes.NewSQLNodeStore(sqlDB)
|
||||
svc := nodes.NewService(ca, tokens, rdb, store)
|
||||
svc.Hub().Start(context.Background()) // pub/sub goroutine runs for process lifetime
|
||||
|
||||
// gRPC transport TLS (server's own cert, typically from Let's Encrypt).
|
||||
serverCert, err := tls.LoadX509KeyPair(grpcCertPath, grpcKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("grpc: load transport TLS cert: %v", err)
|
||||
@@ -204,7 +306,6 @@ func startGRPC(
|
||||
tlsCfg := mtls.NewServerTLSConfig(ca, crl)
|
||||
tlsCfg.Certificates = []tls.Certificate{serverCert}
|
||||
|
||||
// Build gRPC server with mTLS transport + identity interceptors.
|
||||
srv := grpc.NewServer(
|
||||
grpc.Creds(credentials.NewTLS(tlsCfg)),
|
||||
grpc.ChainUnaryInterceptor(mtls.UnaryServerInterceptor()),
|
||||
@@ -222,9 +323,41 @@ func startGRPC(
|
||||
}
|
||||
}
|
||||
|
||||
// parseKeyMap parses "kid1:/path1,kid2:/path2" into a map. Used for JWT_PUBLIC_KEYS.
|
||||
func parseKeyMap(raw string) map[string]string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
m := map[string]string{}
|
||||
for _, pair := range strings.Split(raw, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(pair, ':')
|
||||
if i <= 0 || i == len(pair)-1 {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimSpace(pair[:i])] = strings.TrimSpace(pair[i+1:])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getenvDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func intEnvDefault(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n == 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user