Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ Optional:
- `GITHUB_CALLBACK_URL` - OAuth callback URL (default: `/auth/github/callback`)
- `WEB_SESSION_TTL_HOURS` - How long a minted `storageState` session stays fresh before re-minting (default: 1)
- `WEB_SESSION_MINT_TIMEOUT_SECONDS` - Max time to wait for a broker mint before failing the request (default: 180)
- `GEOIP_DB_DIR` - Directory holding GeoLite2-City.mmdb + GeoLite2-ASN.mmdb (default: `./geoip`). Absent DBs = all non-public agents stay Unverified (safe default; local dev needs no MaxMind account)
- `MAXMIND_LICENSE_KEY` - Free GeoLite2 license key (signup artifact, never a payment), consumed by `scripts/geoip-refresh.sh` (run weekly) — not read by the server itself. When unset, the script automatically falls back to DB-IP Lite (CC-BY-4.0, no account/key; slightly less accurate, no accuracy_radius); force with `--source dbip`
- `GEOIP_DB_DIR` - Directory holding the GeoIP databases (canonical `City.mmdb`/`ASN.mmdb`, falling back to legacy `GeoLite2-City.mmdb`/`GeoLite2-ASN.mmdb` names if present) (default: `./geoip`). Absent DBs = all non-public agents stay Unverified (safe default; local dev needs no MaxMind account) — self-heals on next startup/refresh
- `MAXMIND_LICENSE_KEY` - Optional bootstrap-only GeoLite2 license key (signup artifact, never a payment). The MaxMind key is normally managed from the admin console (Regions page → GeoIP databases card), stored encrypted in `systemConfig` (requires `CREDENTIAL_ENCRYPTION_KEY`); this env var is only consulted as a fallback when no console-managed key is set. No cron needed — Coolify containers have ephemeral filesystems, so `server/geoip-refresh.ts` refreshes in-app instead of via a script: at startup when the DBs are missing/stale (>7 days), on a weekly in-process timer, and on demand via the admin Refresh button / `POST /api/admin/geoip/refresh`. With no key configured (console or env), it automatically falls back to DB-IP Lite (CC-BY-4.0, no account/key; slightly less accurate, no accuracy_radius)

Auth-session broker sidecar (registration env is broker-only, not read by Core — see Auth-Session Broker below):
- `VOX_CORE_URL` - Core base URL the broker registers/heartbeats against
Expand Down
180 changes: 178 additions & 2 deletions client/src/pages/admin-regions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { MapPin, Pencil, Plus, Trash2 } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { MapPin, Pencil, Plus, Trash2, RefreshCw, Database, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -14,6 +15,32 @@ import { useRegionLocations } from "@/hooks/use-regions";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { RegionLocation } from "@/lib/utils";

type GeoipDatabaseInfo = {
name: "City" | "ASN";
present: boolean;
sizeBytes: number | null;
modifiedAt: string | null;
};

type GeoipStatus = {
source: "geolite2" | "dbip";
dir: string;
state: "idle" | "refreshing";
databases: GeoipDatabaseInfo[];
lastRefresh: { ok: boolean; source: string; at: string; error?: string } | null;
attribution: string | null;
maxmindKey: { configured: boolean; source: "console" | "env" | null };
};

function fmtBytes(bytes: number | null): string {
if (bytes === null) return "—";
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function fmtWhen(value: string | null | undefined): string {
return value ? new Date(value).toLocaleString() : "—";
}

type RegionForm = {
baseId: string;
displayName: string;
Expand Down Expand Up @@ -41,6 +68,46 @@ export default function AdminRegions() {
const [editing, setEditing] = useState<RegionLocation | null>(null);
const [form, setForm] = useState<RegionForm>(EMPTY_FORM);

const { data: geoipStatus, isLoading: geoipLoading } = useQuery<GeoipStatus>({
queryKey: ["/api/admin/geoip/status"],
refetchInterval: (query) => (query.state.data?.state === "refreshing" ? 2000 : false),
});
const [maxmindKeyInput, setMaxmindKeyInput] = useState("");

const refreshGeoipMutation = useMutation({
mutationFn: async () => apiRequest("POST", "/api/admin/geoip/refresh"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/geoip/status"] });
toast({ title: "GeoIP refresh started" });
},
onError: (error: Error) => {
toast({ title: "Could not start refresh", description: error.message, variant: "destructive" });
},
});

const saveMaxmindKeyMutation = useMutation({
mutationFn: async (key: string) => apiRequest("PUT", "/api/admin/geoip/maxmind-key", { key }),
onSuccess: () => {
setMaxmindKeyInput("");
queryClient.invalidateQueries({ queryKey: ["/api/admin/geoip/status"] });
toast({ title: "MaxMind key saved", description: "A GeoIP refresh has been triggered." });
},
onError: (error: Error) => {
toast({ title: "Could not save MaxMind key", description: error.message, variant: "destructive" });
},
});

const clearMaxmindKeyMutation = useMutation({
mutationFn: async () => apiRequest("DELETE", "/api/admin/geoip/maxmind-key"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/geoip/status"] });
toast({ title: "MaxMind key cleared" });
},
onError: (error: Error) => {
toast({ title: "Could not clear MaxMind key", description: error.message, variant: "destructive" });
},
});

const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/region-locations"] });
queryClient.invalidateQueries({ queryKey: ["/api/region-locations"] });
Expand Down Expand Up @@ -130,6 +197,115 @@ export default function AdminRegions() {
<Button onClick={openCreate}><Plus className="mr-2 h-4 w-4" />Add Location</Button>
</div>

<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Database className="h-4 w-4" />GeoIP databases</CardTitle>
<CardDescription>
City + ASN databases used for agent region detection. Downloaded from MaxMind GeoLite2 when a license
key is configured, otherwise from DB-IP Lite.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{geoipLoading || !geoipStatus ? (
<Skeleton className="h-32 w-full" />
) : (
<>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="uppercase">
{geoipStatus.source === "geolite2" ? "GeoLite2" : "DB-IP Lite"}
</Badge>
{geoipStatus.state === "refreshing" && (
<Badge className="bg-yellow-500"><RefreshCw className="mr-1 h-3 w-3 animate-spin" />Refreshing…</Badge>
)}
{geoipStatus.lastRefresh && (
<span className="text-xs text-muted-foreground">
Last refresh: {geoipStatus.lastRefresh.ok ? "success" : "failed"} at {fmtWhen(geoipStatus.lastRefresh.at)}
{!geoipStatus.lastRefresh.ok && geoipStatus.lastRefresh.error ? ` — ${geoipStatus.lastRefresh.error}` : ""}
</span>
)}
<Button
size="sm"
variant="outline"
className="ml-auto"
disabled={geoipStatus.state === "refreshing" || refreshGeoipMutation.isPending}
onClick={() => refreshGeoipMutation.mutate()}
>
<RefreshCw className="mr-2 h-4 w-4" />Refresh
</Button>
</div>

<div className="grid gap-2 sm:grid-cols-2">
{geoipStatus.databases.map((db) => (
<div key={db.name} className="flex items-center justify-between border px-3 py-2 text-sm">
<span className="font-medium">{db.name}</span>
{db.present ? (
<span className="text-xs text-muted-foreground">{fmtBytes(db.sizeBytes)} · {fmtWhen(db.modifiedAt)}</span>
) : (
<Badge variant="destructive">Missing</Badge>
)}
</div>
))}
</div>

{geoipStatus.source === "dbip" && geoipStatus.attribution && (
<p className="text-xs text-muted-foreground">
{geoipStatus.attribution} —{" "}
<a href="https://db-ip.com" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline">
db-ip.com<ExternalLink className="h-3 w-3" />
</a>
</p>
)}

<div className="space-y-2 border-t pt-4">
<Label htmlFor="maxmind-key">MaxMind license key</Label>
{geoipStatus.maxmindKey.configured && geoipStatus.maxmindKey.source === "console" ? (
<div className="flex items-center gap-2">
<Badge variant="secondary">Configured (console)</Badge>
<Button
size="sm"
variant="outline"
disabled={clearMaxmindKeyMutation.isPending}
onClick={() => clearMaxmindKeyMutation.mutate()}
>
Clear
</Button>
</div>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{geoipStatus.maxmindKey.configured && geoipStatus.maxmindKey.source === "env" && (
<Badge variant="secondary" className="w-fit">Configured (env)</Badge>
)}
<Input
id="maxmind-key"
type="password"
placeholder={
geoipStatus.maxmindKey.configured && geoipStatus.maxmindKey.source === "env"
? "Override the env key…"
: "Enter a GeoLite2 license key…"
}
value={maxmindKeyInput}
onChange={(e) => setMaxmindKeyInput(e.target.value)}
className="sm:max-w-xs"
/>
<Button
size="sm"
disabled={!maxmindKeyInput.trim() || saveMaxmindKeyMutation.isPending}
onClick={() => saveMaxmindKeyMutation.mutate(maxmindKeyInput.trim())}
>
Save
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
Saving a key immediately triggers a refresh and switches the source to GeoLite2. Removing it falls
back to the env var (if set) or DB-IP Lite.
</p>
</div>
</>
)}
</CardContent>
</Card>

<div className="border">
<Table>
<TableHeader>
Expand Down
86 changes: 0 additions & 86 deletions scripts/geoip-refresh.sh

This file was deleted.

Loading
Loading