Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user