Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-integration (push) Successful in 1m34s
CI / tests-unit (push) Successful in 1m36s
CI / tests-ui (push) Successful in 2m22s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m53s
- Bound the in-process verdict cache (FIFO eviction at 2000 entries) so a
flood of distinct bucket-tripping IPs cannot grow it without limit.
- Record block metadata (reputation, score, behaviors, category, TTL) in
antiddos:block:meta:{ip}, surfaced as the reason in the admin block list;
unban now also clears the metadata and report locks.
- Track daily CTI enrichment usage in Redis (crowdsec:usage:{date}); warn
once at 80% and pause lookups until tomorrow at CROWDSEC_CTI_DAILY_QUOTA
(default 10000, 0 = unlimited) so a via-spread DDoS cannot burn the plan.
- Add opt-in signal push to the CrowdSec community (CAPI watcher): stable
auto-generated 48-char machine_id/password pair persisted in Redis (or via
env), one-time registration, cached JWT login, optional Console enrollment,
and POST /v3/signals with a ban decision, deduped per IP. Never throws and
reports last status to the admin panel with a verify action.
- Admin page: quota usage bar, reporting status/verify channel, and CrowdSec
block reasons in the active-blocks list.
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
import { z } from "zod";
|
|
|
|
// Minimal validated env for the foundation. When the Next.js app is added this
|
|
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
|
|
// only needs the DB connection + a couple of values today.
|
|
const schema = z
|
|
.object({
|
|
NODE_ENV: z
|
|
.enum(["development", "test", "production"])
|
|
.default("development"),
|
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => value === "true" || value === "1"),
|
|
DATABASE_URL: z.string().url(),
|
|
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
|
|
DATABASE_IDLE_TIMEOUT_MS: z.coerce
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.default(300_000),
|
|
DATABASE_CONNECT_TIMEOUT_MS: z.coerce
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.default(10_000),
|
|
HOTEL_NAME: z
|
|
.string()
|
|
.min(
|
|
1,
|
|
"HOTEL_NAME is not set — the site has not been configured/built yet.",
|
|
),
|
|
APP_URL: z.string().url().default("http://localhost:3000"),
|
|
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
|
|
// Public imager URL — overrides the default /imaging relative path.
|
|
NEXT_PUBLIC_IMAGER_URL: z.string().optional(),
|
|
// Upstream avatar imager proxy (defaults to Habbo's public imager).
|
|
IMAGING_UPSTREAM_URL: z.string().optional(),
|
|
// Resend API key (preferred — simpler HTTP API, always works).
|
|
RESEND_API_KEY: z.string().optional(),
|
|
// SMTP (password reset / notifications). Email features no-op if unset.
|
|
SMTP_HOST: z.string().optional(),
|
|
SMTP_PORT: z.coerce.number().int().positive().optional(),
|
|
SMTP_SECURE: z
|
|
.string()
|
|
.optional()
|
|
.transform((v) => v === "true" || v === "1"),
|
|
SMTP_USER: z.string().optional(),
|
|
SMTP_PASSWORD: z.string().optional(),
|
|
SMTP_FROM: z.string().optional(),
|
|
// RCON link to the Arcturus emulator (raw-JSON TCP protocol).
|
|
RCON_HOST: z.string().default("127.0.0.1"),
|
|
RCON_PORT: z.coerce.number().int().positive().default(3001),
|
|
RCON_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
|
|
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
|
|
// Emulator transport selection. "rcon" uses the raw-JSON TCP protocol
|
|
// above; "api" uses an HTTP endpoint that accepts the same
|
|
// `{"key":...,"data":...}` payload (for emulators without RCON).
|
|
EMULATOR_MODE: z.enum(["rcon", "api"]).default("rcon"),
|
|
// HTTP endpoint used when EMULATOR_MODE=api. Required in that mode.
|
|
EMULATOR_API_URL: z.string().url().optional(),
|
|
// Optional bearer/key auth for the API transport. When set, it is sent
|
|
// as `${EMULATOR_API_KEY_HEADER}: ${EMULATOR_API_KEY}` (default header
|
|
// "Authorization").
|
|
EMULATOR_API_KEY: z.string().optional(),
|
|
EMULATOR_API_KEY_HEADER: z.string().default("Authorization"),
|
|
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
|
AUTH_SECRET: z.string().min(1).optional(),
|
|
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
|
APP_KEY: z.string().optional(),
|
|
|
|
// bcrypt cost factor used for new password hashes.
|
|
BCRYPT_COST: z.coerce.number().int().min(4).max(31).default(12),
|
|
// Filesystem dir the badge uploader writes <code>.gif into (the emulator's
|
|
// badge image folder, e.g. .../assets/c_images/album1584). Upload is disabled
|
|
// when unset.
|
|
BADGE_UPLOAD_DIR: z.string().optional(),
|
|
// Emulator JAR backup job (jobs-worker, host-side); no-op unless both set.
|
|
EMULATOR_JAR_PATH: z.string().optional(),
|
|
EMULATOR_BACKUP_DIR: z.string().optional(),
|
|
EMULATOR_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
|
|
// Optional mysqldump backup (jobs-worker); requires mysqldump on PATH.
|
|
DB_BACKUP_DIR: z.string().optional(),
|
|
DB_BACKUP_KEEP: z.coerce.number().int().positive().optional(),
|
|
// Minutes between repeat health-fail Discord/email alerts (jobs-worker).
|
|
HEALTH_ALERT_COOLDOWN_MIN: z.coerce.number().int().positive().optional(),
|
|
// Optional AI content moderation (comments / guestbook).
|
|
OPENAI_API_KEY: z.string().optional(),
|
|
// Optional alerting (jobs worker / alert service).
|
|
DISCORD_WEBHOOK_URL: z.string().url().optional(),
|
|
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
|
TELEGRAM_CHAT_ID: z.string().optional(),
|
|
ALERT_EMAIL: z.string().optional(),
|
|
// Optional PayPal top-up.
|
|
PAYPAL_CLIENT_ID: z.string().optional(),
|
|
PAYPAL_SECRET: z.string().optional(),
|
|
PAYPAL_API: z.string().url().optional(),
|
|
PAYPAL_CURRENCY: z.string().default("USD"),
|
|
PAYPAL_CREDITS_PER_USD: z.coerce.number().positive().default(100),
|
|
// Redis — strongly recommended in production (required for multi-instance).
|
|
// Without it, rate limits / shared caches are in-process only.
|
|
REDIS_URL: z.string().optional(),
|
|
// App-layer anti-DDoS gate (proxy rate limiter). On by default in
|
|
// production; set to "false" or "0" to disable without removing it.
|
|
ANTI_DDOS_ENABLED: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => value !== "false" && value !== "0"),
|
|
// Anti-DDoS thresholds. These are the YAML-file boot defaults; the admin
|
|
// panel can override them at runtime (Redis `antiddos:config`).
|
|
ANTI_DDOS_PAGES_LIMIT: z.coerce.number().int().positive().default(300),
|
|
ANTI_DDOS_PAGES_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
|
ANTI_DDOS_API_LIMIT: z.coerce.number().int().positive().default(600),
|
|
ANTI_DDOS_API_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
|
ANTI_DDOS_AUTH_LIMIT: z.coerce.number().int().positive().default(20),
|
|
ANTI_DDOS_AUTH_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
|
ANTI_DDOS_GLOBAL_LIMIT: z.coerce.number().int().positive().default(18_000),
|
|
ANTI_DDOS_GLOBAL_WINDOW_SEC: z.coerce.number().int().positive().default(60),
|
|
ANTI_DDOS_VIOLATION_WINDOW_SEC: z.coerce
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.default(600),
|
|
ANTI_DDOS_MAX_VIOLATIONS: z.coerce.number().int().positive().default(10),
|
|
// Escalation tiers as "minViolations:ttlSeconds,minViolations:ttlSeconds".
|
|
ANTI_DDOS_BLOCK_TIERS: z.string().optional(),
|
|
ANTI_DDOS_GLOBAL_HALT_MS: z.coerce
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.default(10_000),
|
|
// Logging level.
|
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
|
APP_VERSION: z.string().optional(),
|
|
// Cloudflare API — optional. When the API token + zone id are set, the
|
|
// anti-DDoS gate can automatically mirror escalated IP blocks to the
|
|
// zone's IP Access Rules so attackers are dropped at the edge. The token
|
|
// lives in env only and is never persisted into Redis-visible config.
|
|
CLOUDFLARE_API_BASE_URL: z
|
|
.string()
|
|
.url()
|
|
.default("https://api.cloudflare.com/client/v4"),
|
|
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
|
CLOUDFLARE_ZONE_ID: z.string().optional(),
|
|
// Boot default for the runtime "auto-create Cloudflare blocks" toggle
|
|
// (overridable via the admin panel / antiddos:config).
|
|
CLOUDFLARE_AUTO_BLOCK_ENABLED: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => value !== "false" && value !== "0"),
|
|
// CrowdSec API — optional. When the CTI API key is set, the anti-DDoS
|
|
// gate consults the community reputation of repeat offenders (CTI
|
|
// GET /smoke/{ip}) and hard-blocks known-bad IPs immediately. Like the
|
|
// Cloudflare token, the key lives in env only and is never written into
|
|
// the admin-visible config. Free/community key: app.crowdsec.net →
|
|
// Settings → CTI API Keys.
|
|
CROWDSEC_API_KEY: z.string().optional(),
|
|
// Reputation lookup (CTI) endpoint; overridden for tests/staging.
|
|
CROWDSEC_CTI_BASE_URL: z
|
|
.string()
|
|
.url()
|
|
.default("https://cti.api.crowdsec.net/v2"),
|
|
// Boot default for the runtime "auto-block from CrowdSec reputation"
|
|
// toggle (overridable via the admin panel / antiddos:config).
|
|
CROWDSEC_AUTO_BLOCK_ENABLED: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => value !== "false" && value !== "0"),
|
|
// Minimum malevolence score (CrowdSec scores are 0-5; 4-5 maps to
|
|
// "malicious") an IP must reach before the gate treats it as known-bad.
|
|
// An IP the community already labels "malicious" is always blocked,
|
|
// unless it carries false-positive classification tags.
|
|
CROWDSEC_BLOCK_SCORE: z.coerce.number().int().min(0).max(5).default(4),
|
|
// How long a CrowdSec-confirmed bad IP stays blocked by the gate.
|
|
CROWDSEC_BLOCK_TTL_SECONDS: z.coerce
|
|
.number()
|
|
.int()
|
|
.positive()
|
|
.default(86_400),
|
|
// Daily CTI enrichment quota guard (freemium plan ≈ 10k lookups/day).
|
|
// The gate stops consulting the API once the counter for today exceeds
|
|
// it, so a spread DDoS can never silently burn the whole quota; 0
|
|
// disables the guard.
|
|
CROWDSEC_CTI_DAILY_QUOTA: z.coerce.number().int().min(0).default(10_000),
|
|
// Share our own detections back into the CrowdSec community blocklist
|
|
// (signal push over the Central API). Opt-in: flipping this on publicly
|
|
// shares blocked IPs + behaviors, so it defaults to off.
|
|
CROWDSEC_REPORT_ENABLED: z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => value === "true" || value === "1"),
|
|
// Central API (CAPI) base endpoint; overridden for tests/staging.
|
|
CROWDSEC_CAPI_BASE_URL: z
|
|
.string()
|
|
.url()
|
|
.default("https://api.crowdsec.net/v3"),
|
|
// Watcher credentials for signal push. When omitted, a stable pair is
|
|
// generated once and persisted in Redis (48-char alnum machine id,
|
|
// per the CAPI schema).
|
|
CROWDSEC_REPORT_MACHINE_ID: z.string().optional(),
|
|
CROWDSEC_REPORT_PASSWORD: z.string().optional(),
|
|
// Optional attachment key from the CrowdSec Console — links our
|
|
// watcher to your account so pushed signals show up there.
|
|
CROWDSEC_REPORT_ENROLL_KEY: z.string().optional(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.NODE_ENV !== "production") return;
|
|
// AUTH_SECRET
|
|
if (!data.AUTH_SECRET || data.AUTH_SECRET.length < 32) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message:
|
|
"AUTH_SECRET (>=32 characters) is required when NODE_ENV=production",
|
|
path: ["AUTH_SECRET"],
|
|
});
|
|
}
|
|
// PayPal credentials must be paired.
|
|
if (data.PAYPAL_CLIENT_ID && !data.PAYPAL_SECRET) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "PAYPAL_SECRET is required when PAYPAL_CLIENT_ID is set",
|
|
path: ["PAYPAL_SECRET"],
|
|
});
|
|
}
|
|
if (!data.PAYPAL_CLIENT_ID && data.PAYPAL_SECRET) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: "PAYPAL_CLIENT_ID is required when PAYPAL_SECRET is set",
|
|
path: ["PAYPAL_CLIENT_ID"],
|
|
});
|
|
}
|
|
});
|
|
|
|
type Env = z.infer<typeof schema>;
|
|
|
|
// SKIP_ENV_VALIDATION is for tooling only (vitest). Production deploy must NOT
|
|
// set this — builds should validate AUTH_SECRET, DATABASE_URL, etc.
|
|
export const env: Env = process.env.SKIP_ENV_VALIDATION
|
|
? (process.env as unknown as Env)
|
|
: schema.parse(process.env);
|