Initial commit

This commit is contained in:
siliconsolder committed 2026-09-07 09:54:30 +00:00
commit 38c24fc9dc
166 files changed
+22002

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { isIpInAnyCidr } from './cidr.js'
describe('isIpInAnyCidr', () => {
it('matches an address inside a /8', () => {
expect(isIpInAnyCidr('10.1.2.3', ['10.0.0.0/8'])).toBe(true)
})
it('rejects an address outside all ranges', () => {
expect(isIpInAnyCidr('8.8.8.8', ['10.0.0.0/8', '192.168.0.0/16'])).toBe(false)
})
it('matches an exact /32', () => {
expect(isIpInAnyCidr('127.0.0.1', ['127.0.0.1/32'])).toBe(true)
expect(isIpInAnyCidr('127.0.0.2', ['127.0.0.1/32'])).toBe(false)
})
it('treats 0.0.0.0/0 as match-all', () => {
expect(isIpInAnyCidr('203.0.113.5', ['0.0.0.0/0'])).toBe(true)
})
it('normalizes IPv4-mapped IPv6 addresses', () => {
expect(isIpInAnyCidr('::ffff:192.168.1.50', ['192.168.0.0/16'])).toBe(true)
})
it('returns false when no CIDRs are configured', () => {
expect(isIpInAnyCidr('10.0.0.1', [])).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
// Minimal, dependency-free CIDR matching. IPv4-focused (the realistic case
// for a LAN homelab); IPv6 support is "exact address" or an explicit "::/0"
// wildcard only — full IPv6 prefix matching can be added later if it's ever
// actually needed.
function normalizeIp(ip: string): string {
// Node reports IPv4 clients as "::ffff:x.x.x.x" on a dual-stack listener.
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
// The pattern has exactly one capture group, so a successful match always
// populates it.
return v4mapped ? v4mapped[1]! : ip
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let result = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
result = (result << 8) | n
}
return result >>> 0
}
function matchesCidr(ip: string, cidr: string): boolean {
const trimmed = cidr.trim()
if (trimmed === '0.0.0.0/0' || trimmed === '::/0') return true
const [rangeIp, prefixStr] = trimmed.split('/')
const ipInt = ipv4ToInt(ip)
const rangeInt = rangeIp ? ipv4ToInt(rangeIp) : null
if (ipInt === null || rangeInt === null) {
// Not IPv4 on one side (e.g. an IPv6 literal) — fall back to exact match.
return ip === rangeIp
}
const prefixLength = prefixStr === undefined ? 32 : Number(prefixStr)
if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > 32) return false
if (prefixLength === 0) return true
const mask = prefixLength === 32 ? 0xffffffff : (~0 << (32 - prefixLength)) >>> 0
return (ipInt & mask) === (rangeInt & mask)
}
export function isIpInAnyCidr(ip: string, cidrs: readonly string[]): boolean {
if (cidrs.length === 0) return false
const normalized = normalizeIp(ip)
return cidrs.some((cidr) => matchesCidr(normalized, cidr))
}
+35
View File
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
// Small AES-256-GCM envelope for the session cookie payload — deliberately
// not using a library like @fastify/secure-session here, since its
// sodium-native dependency has no prebuilt binary for Alpine/musl (see the
// comment in modules/auth/session.ts). Node's built-in crypto needs no
// native compilation.
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12
const AUTH_TAG_LENGTH = 16
export function encryptJson(key: Buffer, data: unknown): string {
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, key, iv)
const plaintext = Buffer.from(JSON.stringify(data), 'utf8')
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, authTag, encrypted]).toString('base64url')
}
export function decryptJson<T>(key: Buffer, value: string): T | null {
try {
const buf = Buffer.from(value, 'base64url')
const iv = buf.subarray(0, IV_LENGTH)
const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH)
const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH)
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return JSON.parse(decrypted.toString('utf8')) as T
} catch {
// Tampered, expired-key-rotated, or malformed cookie — treat as no session.
return null
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { isQuietPriority, isWithinQuietHours, resolvePriority } from './priority.js'
describe('resolvePriority', () => {
it('defaults to 3 when no value is given', () => {
expect(resolvePriority(null)).toBe(3)
expect(resolvePriority(undefined)).toBe(3)
expect(resolvePriority('')).toBe(3)
})
it('maps ntfy word aliases to their numeric levels', () => {
expect(resolvePriority('min')).toBe(1)
expect(resolvePriority('low')).toBe(2)
expect(resolvePriority('default')).toBe(3)
expect(resolvePriority('high')).toBe(4)
expect(resolvePriority('urgent')).toBe(5)
expect(resolvePriority('max')).toBe(5)
})
it('is case-insensitive and trims whitespace on word aliases', () => {
expect(resolvePriority('MIN')).toBe(1)
expect(resolvePriority(' min ')).toBe(1)
})
it('parses bare numeric strings', () => {
expect(resolvePriority('1')).toBe(1)
expect(resolvePriority('5')).toBe(5)
})
it('falls back to default for unrecognized input', () => {
expect(resolvePriority('not-a-priority')).toBe(3)
})
})
describe('isQuietPriority', () => {
it('is true only at min/1 and below', () => {
expect(isQuietPriority('min')).toBe(true)
expect(isQuietPriority('1')).toBe(true)
expect(isQuietPriority('low')).toBe(false)
expect(isQuietPriority('2')).toBe(false)
expect(isQuietPriority('default')).toBe(false)
expect(isQuietPriority(null)).toBe(false)
})
})
describe('isWithinQuietHours', () => {
it('handles a same-day window', () => {
const noon = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('09:00', '17:00', 'UTC', noon)).toBe(true)
const evening = new Date('2026-01-01T20:00:00Z')
expect(isWithinQuietHours('09:00', '17:00', 'UTC', evening)).toBe(false)
})
it('handles a window that wraps past midnight', () => {
const lateNight = new Date('2026-01-01T23:30:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', lateNight)).toBe(true)
const earlyMorning = new Date('2026-01-01T05:00:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', earlyMorning)).toBe(true)
const midday = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('23:00', '07:00', 'UTC', midday)).toBe(false)
})
it('treats an identical start/end as always off', () => {
const anytime = new Date('2026-01-01T12:00:00Z')
expect(isWithinQuietHours('09:00', '09:00', 'UTC', anytime)).toBe(false)
})
it('resolves the window in the given timezone, not UTC', () => {
// 22:30 in Stockholm (UTC+1 in January) is 21:30 UTC — inside a
// 22:00-06:00 window in Stockholm time, but not if evaluated in UTC.
const time = new Date('2026-01-01T21:30:00Z')
expect(isWithinQuietHours('22:00', '06:00', 'Europe/Stockholm', time)).toBe(true)
expect(isWithinQuietHours('22:00', '06:00', 'UTC', time)).toBe(false)
})
})
+58
View File
@@ -0,0 +1,58 @@
// ntfy-style priority scale (docs/migration-ntfy.md) — reused here so a
// value already familiar to anyone migrating from ntfy behaves the way
// they'd expect: "min"/1 means "log it, don't buzz the phone."
const PRIORITY_ALIASES: Record<string, number> = {
min: 1,
low: 2,
default: 3,
high: 4,
urgent: 5,
max: 5,
}
const DEFAULT_PRIORITY = 3
const MIN_PRIORITY_NO_PUSH = 1
export function resolvePriority(raw: string | null | undefined): number {
if (!raw) return DEFAULT_PRIORITY
const trimmed = raw.trim().toLowerCase()
// The `in` check above guarantees the key exists.
if (trimmed in PRIORITY_ALIASES) return PRIORITY_ALIASES[trimmed]!
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : DEFAULT_PRIORITY
}
// "min" priority notifications are stored and shown in-app like any other
// — they just don't trigger a push, so a noisy automation can stay in its
// normal topic instead of needing a dedicated quiet topic.
export function isQuietPriority(raw: string | null | undefined): boolean {
return resolvePriority(raw) <= MIN_PRIORITY_NO_PUSH
}
// Household-wide "quiet hours" window (Admin > System) — true when `now`
// falls inside [start, end) in the given IANA timezone. Handles a window
// that wraps past midnight (e.g. 23:00-07:00) by checking OR instead of
// AND once start > end.
export function isWithinQuietHours(start: string, end: string, timezone: string, now = new Date()): boolean {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0')
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0')
const nowMinutes = hour * 60 + minute
const [startH, startM] = start.split(':').map(Number)
const [endH, endM] = end.split(':').map(Number)
const startMinutes = (startH ?? 0) * 60 + (startM ?? 0)
const endMinutes = (endH ?? 0) * 60 + (endM ?? 0)
if (startMinutes === endMinutes) return false
if (startMinutes < endMinutes) {
return nowMinutes >= startMinutes && nowMinutes < endMinutes
}
return nowMinutes >= startMinutes || nowMinutes < endMinutes
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { createDualWindowLimiter, TokenBucket } from './rate-limiter.js'
describe('TokenBucket', () => {
it('allows up to capacity requests, then rejects', () => {
const bucket = new TokenBucket(3, 60_000)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('a')).toBe(false)
})
it('tracks separate keys independently', () => {
const bucket = new TokenBucket(1, 60_000)
expect(bucket.tryConsume('a')).toBe(true)
expect(bucket.tryConsume('b')).toBe(true)
expect(bucket.tryConsume('a')).toBe(false)
})
})
describe('createDualWindowLimiter', () => {
it('rejects once the smaller (burst) window is exhausted', () => {
const limiter = createDualWindowLimiter(100, 60_000, 2, 10_000)
expect(limiter.check('k')).toBe(true)
expect(limiter.check('k')).toBe(true)
expect(limiter.check('k')).toBe(false)
})
it('does not drain the sustained window on a burst-only rejection', () => {
const limiter = createDualWindowLimiter(100, 60_000, 1, 10_000)
expect(limiter.check('k')).toBe(true)
// Burst window is now empty; several more calls should all be
// rejected by burst without touching the (still nearly full) sustained
// bucket.
for (let i = 0; i < 5; i++) {
expect(limiter.check('k')).toBe(false)
}
})
})
+71
View File
@@ -0,0 +1,71 @@
// In-memory token bucket rate limiter. Single-instance app (see
// docs/phase1/01-architecture.md — no Redis), so process memory is a fine
// place for this; it resets on restart, which is an acceptable trade-off
// for a homelab-scale rate limiter.
interface Bucket {
tokens: number
lastRefillMs: number
}
export class TokenBucket {
private buckets = new Map<string, Bucket>()
constructor(
private readonly capacity: number,
private readonly windowMs: number,
) {}
private refillRatePerMs(): number {
return this.capacity / this.windowMs
}
private currentTokens(key: string, now: number): { bucket: Bucket; tokens: number } {
const bucket = this.buckets.get(key) ?? { tokens: this.capacity, lastRefillMs: now }
const elapsed = now - bucket.lastRefillMs
const tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillRatePerMs())
return { bucket, tokens }
}
/** Non-mutating: how many tokens would be available right now. */
peek(key: string): number {
return this.currentTokens(key, Date.now()).tokens
}
/** Spends one token. Only call after peek() (or checkRateLimit) confirmed room. */
consume(key: string): void {
const now = Date.now()
const { bucket, tokens } = this.currentTokens(key, now)
bucket.tokens = tokens - 1
bucket.lastRefillMs = now
this.buckets.set(key, bucket)
}
/** Checks and, if there's room, consumes in one step. */
tryConsume(key: string): boolean {
if (this.peek(key) < 1) return false
this.consume(key)
return true
}
}
export function createDualWindowLimiter(
sustainedCapacity: number,
sustainedWindowMs: number,
burstCapacity: number,
burstWindowMs: number,
) {
const sustained = new TokenBucket(sustainedCapacity, sustainedWindowMs)
const burst = new TokenBucket(burstCapacity, burstWindowMs)
// Checks both windows for `key` and consumes one token from each only if
// BOTH have room — a request that fails one window never silently drains
// the other.
return {
check(key: string): boolean {
if (burst.peek(key) < 1 || sustained.peek(key) < 1) return false
burst.consume(key)
sustained.consume(key)
return true
},
}
}