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
14 changes: 12 additions & 2 deletions app/(app)/projects/[id]/security/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import { createClient } from "@/lib/supabase/server";

export const dynamic = "force-dynamic";

const POLL_MS = 1500;
const MAX_MS = 5 * 60 * 1000;
const POLL_MS = 2000;
// A full 65535-port scan can run many minutes; keep the stream open long
// enough to see it finish.
const MAX_MS = 45 * 60 * 1000;
// Emit an SSE comment periodically so idle proxies don't drop the connection
// while a long scan sits in `running` with no status change.
const HEARTBEAT_MS = 15_000;

export async function GET(
req: NextRequest,
Expand Down Expand Up @@ -48,8 +53,13 @@ export async function GET(

const deadline = Date.now() + MAX_MS;
let lastStatus = "";
let lastBeat = Date.now();
try {
while (!closed && Date.now() < deadline) {
if (Date.now() - lastBeat >= HEARTBEAT_MS) {
if (!closed) controller.enqueue(encoder.encode(`: keep-alive\n\n`));
lastBeat = Date.now();
}
const base = supabase
.from("port_scans")
.select("id, status, open_ports, completed_at")
Expand Down
13 changes: 8 additions & 5 deletions docs/uptime-monitoring-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,12 @@ a scanner.
can't take down production. GCP and Railway AUPs prohibit network scanning;
scanning from the app IP risks the service's IP. The droplet has its own IP
reputation and raw-socket access. See §12.3 for the job-transport design.
- **Bounded + TCP-connect only.** Curated **top-~100 common service ports**, never
full 65535; TCP `connect()` only (containers lack `CAP_NET_RAW` for SYN scans
anyway); ICMP discovery skipped (`-Pn`-equivalent).
- **Full port range, TCP-connect only.** Scans **all 65535 TCP ports** (`nmap
-sT -Pn -p- -T4`) so nothing exposed is missed — the point of the feature. Only
safe because targets are owner-verified and the scan runs on a dedicated
off-Railway droplet. TCP `connect()` only (containers lack `CAP_NET_RAW` for SYN
scans anyway); ICMP discovery skipped (`-Pn`); a 25-min nmap `--host-timeout`
bounds heavily-filtered hosts.
- **Rate-limited + infrequent.** Daily cadence, not per-minute; per-org caps.

### 12.2 Behavior
Expand All @@ -276,8 +279,8 @@ Railway (producer) Redis (broker) DO droplet (prober)
────────────────── ────────────── ───────────────────
API / worker enqueues ──▶ "prober" queue ◀── BullMQ Worker dials OUT
port-scan jobs (rediss:// TLS) over rediss://, runs
(repeatable = daily) nmap -sT -Pn --top-ports
100, returns result
(repeatable = daily) nmap -sT -Pn -p- -T4
(all ports), returns result
Railway QueueEvents ◀─── job "completed" ◀── (result = job return value)
handler persists to
Supabase, diffs
Expand Down
5 changes: 3 additions & 2 deletions lib/prober-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ const HIGH_RISK_PORTS = new Set([
]);
const LOW_RISK_PORTS = new Set([80, 443]);

// A running scan older than this is treated as timed out.
const RUNNING_TIMEOUT_MS = 15 * 60 * 1000;
// A running scan older than this is treated as timed out. Generous because a
// full 65535-port scan (nmap -p-) can take many minutes on a filtered host.
const RUNNING_TIMEOUT_MS = 40 * 60 * 1000;

let queue: Queue | null = null;

Expand Down
12 changes: 7 additions & 5 deletions prober/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// A BullMQ Worker running on a self-hosted DigitalOcean droplet. It dials OUT
// to Redis (rediss://) — no inbound port — pulls port-scan jobs off the
// "prober" queue, runs a bounded `nmap -sT -Pn --top-ports 100` TCP-connect
// "prober" queue, runs a full `nmap -sT -Pn -p- -T4` (all 65535) TCP-connect
// scan, and returns the open-port set as the job's return value. It writes no
// database; the Railway side handles results (baseline diff + alerts).
import { Worker, type Job, type ConnectionOptions } from "bullmq";
Expand Down Expand Up @@ -60,12 +60,14 @@ function assertScannableHost(host: string): void {

async function scan(job: PortScanJob): Promise<PortScanResult> {
assertScannableHost(job.host);
// -sT connect scan (no CAP_NET_RAW needed), -Pn skip host discovery,
// --top-ports 100 bounded set, -oG - greppable output on stdout.
// Full TCP port scan (all 65535): -sT connect scan (no CAP_NET_RAW needed),
// -Pn skip host discovery, -p- every port, -T4 faster timing, and a 25-min
// nmap-side host timeout so a heavily-filtered host returns what it found
// instead of hanging. -oG - greppable output on stdout.
const { stdout } = await pexecFile(
"nmap",
["-sT", "-Pn", "--top-ports", "100", "-oG", "-", job.host],
{ timeout: 180_000, maxBuffer: 8 * 1024 * 1024 },
["-sT", "-Pn", "-p-", "-T4", "--host-timeout", "1500s", "-oG", "-", job.host],
{ timeout: 30 * 60_000, maxBuffer: 16 * 1024 * 1024 },
);

const openPorts: OpenPort[] = [];
Expand Down
Loading