diff --git a/server/constants/server.ts b/server/constants/server.ts deleted file mode 100644 index d0916cc..0000000 --- a/server/constants/server.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const ROUTES = { - FAVICON: '/favicon.png', - HTMX: '/htmx.js', - ADMIN: '/', - ADMIN_CSS: '/admin.css', - HEALTH_FRAGMENT: '/health/fragment', - SIGNAL_INFO_FRAGMENT: '/signal-info/fragment', - ENDPOINTS_FRAGMENT: '/endpoints/fragment', - QR_SECTION: '/link/qr-section', - QR_IMAGE: '/link/qr-image', - STATUS_CHECK: '/link/status-check', - MATRIX_NOTIFY: '/_matrix/push/v1/notify', - UP: '/up', - UP_INSTANCE: '/up/:instance', -} as const; diff --git a/server/index.ts b/server/index.ts index 615b601..ac7567c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,7 +1,7 @@ import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config'; -import { ROUTES } from '@/constants/server'; import { cleanupDaemon, initSignal } from '@/modules/signal'; import { adminRoutes } from '@/routes/admin/index'; +import { ntfyRoutes } from '@/routes/ntfy'; import { unifiedPushRoutes } from '@/routes/unifiedpush'; import { logError, logInfo, logWarn } from '@/utils/log'; @@ -30,12 +30,18 @@ const server = Bun.serve({ idleTimeout: 60, routes: { - [ROUTES.FAVICON]: Bun.file('public/favicon.png'), - [ROUTES.HTMX]: Bun.file('node_modules/htmx.org/dist/htmx.min.js'), + '/favicon.png': { + GET: () => new Response(Bun.file('public/favicon.png')), + }, + '/htmx.js': { + GET: () => new Response(Bun.file('node_modules/htmx.org/dist/htmx.min.js')), + }, ...adminRoutes, ...unifiedPushRoutes, + + ...ntfyRoutes, }, }); diff --git a/server/modules/signal.ts b/server/modules/signal.ts index 356817d..a4ffb91 100644 --- a/server/modules/signal.ts +++ b/server/modules/signal.ts @@ -153,6 +153,11 @@ export async function startDaemon() { let authError = false; let cleaned = false; + if (daemon && !daemon.killed) { + daemon.kill(); + await Bun.sleep(500); + } + try { await unlink(SIGNAL_CLI_SOCKET); logVerbose('Removed stale socket file'); diff --git a/server/public/admin.css b/server/public/admin.css index 69d7d85..6f48dd8 100644 --- a/server/public/admin.css +++ b/server/public/admin.css @@ -64,7 +64,7 @@ h2 { .status { display: flex; - gap: 10px; + gap: 15px; flex-wrap: wrap; } diff --git a/server/routes/admin/fragments.ts b/server/routes/admin/fragments.ts index 9cb84d2..fc0f8c4 100644 --- a/server/routes/admin/fragments.ts +++ b/server/routes/admin/fragments.ts @@ -12,8 +12,9 @@ import { unlinkDevice, } from '@/modules/signal'; import { getAllMappings } from '@/modules/store'; +import { withAuth } from '@/utils/auth'; -export const handleHealthFragment = async () => { +const handleHealthFragment = async () => { const signalOk = await checkSignalCli(); const linked = signalOk && (await hasValidAccount()); const imap = isImapConnected(); @@ -27,7 +28,7 @@ export const handleHealthFragment = async () => { Account: ${linked ? 'Linked' : 'Unlinked'}
- IMAP: ${imap ? 'Connected' : 'Disconnected'} + ProtonMail: ${imap ? 'Connected' : 'Disconnected'}
`; @@ -37,7 +38,7 @@ export const handleHealthFragment = async () => { }); }; -export const handleSignalInfoFragment = async () => { +const handleSignalInfoFragment = async () => { const linked = await hasValidAccount(); const html = linked @@ -63,7 +64,7 @@ export const handleSignalInfoFragment = async () => { }); }; -export const handleEndpointsFragment = async () => { +const handleEndpointsFragment = async () => { const endpoints = getAllMappings(); if (endpoints.length === 0) { @@ -83,7 +84,7 @@ export const handleEndpointsFragment = async () => { }); }; -export const handleQRSection = async () => { +const handleQRSection = async () => { const html = `

Scan this QR code with your Signal app:

Settings → Linked Devices → Link New Device

@@ -103,7 +104,7 @@ export const handleQRSection = async () => { }); }; -export const handleQRImage = async () => { +const handleQRImage = async () => { const linked = await hasValidAccount(); if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) { await unlinkDevice(); @@ -118,7 +119,7 @@ export const handleQRImage = async () => { }); }; -export const handleLinkStatusCheck = async () => { +const handleLinkStatusCheck = async () => { let linked = await hasValidAccount(); if (!linked && hasLinkUri()) { @@ -144,3 +145,29 @@ export const handleLinkStatusCheck = async () => { headers: { 'content-type': 'text/html' }, }); }; + +export const fragmentRoutes = { + '/health/fragment': { + GET: withAuth(handleHealthFragment), + }, + + '/signal-info/fragment': { + GET: withAuth(handleSignalInfoFragment), + }, + + '/endpoints/fragment': { + GET: withAuth(handleEndpointsFragment), + }, + + '/link/qr-section': { + GET: withAuth(handleQRSection), + }, + + '/link/qr-image': { + GET: withAuth(handleQRImage), + }, + + '/link/status-check': { + GET: withAuth(handleLinkStatusCheck), + }, +}; diff --git a/server/routes/admin/index.ts b/server/routes/admin/index.ts index b026c53..743db7d 100644 --- a/server/routes/admin/index.ts +++ b/server/routes/admin/index.ts @@ -1,42 +1,13 @@ -import { ROUTES } from '@/constants/server'; -import { withAuth } from '@/utils/auth'; -import { - handleEndpointsFragment, - handleHealthFragment, - handleLinkStatusCheck, - handleQRImage, - handleQRSection, - handleSignalInfoFragment, -} from './fragments'; +import { fragmentRoutes } from './fragments'; export const adminRoutes = { - [ROUTES.ADMIN]: { + '/': { GET: () => new Response(Bun.file('public/admin.html')), }, - [ROUTES.ADMIN_CSS]: Bun.file('public/admin.css'), - - [ROUTES.HEALTH_FRAGMENT]: { - GET: withAuth(handleHealthFragment), + '/admin.css': { + GET: () => new Response(Bun.file('public/admin.css')), }, - [ROUTES.SIGNAL_INFO_FRAGMENT]: { - GET: withAuth(handleSignalInfoFragment), - }, - - [ROUTES.ENDPOINTS_FRAGMENT]: { - GET: withAuth(handleEndpointsFragment), - }, - - [ROUTES.QR_SECTION]: { - GET: withAuth(handleQRSection), - }, - - [ROUTES.QR_IMAGE]: { - GET: withAuth(handleQRImage), - }, - - [ROUTES.STATUS_CHECK]: { - GET: withAuth(handleLinkStatusCheck), - }, + ...fragmentRoutes, }; diff --git a/server/routes/ntfy.ts b/server/routes/ntfy.ts new file mode 100644 index 0000000..ae8092a --- /dev/null +++ b/server/routes/ntfy.ts @@ -0,0 +1,60 @@ +import { createGroup, sendGroupMessage } from '@/modules/signal'; +import { getGroupId, register } from '@/modules/store'; +import { withAuth } from '@/utils/auth'; +import { logError, logVerbose } from '@/utils/log'; + +const handleNtfyPublish = async (req: Request) => { + try { + const url = new URL(req.url); + const topic = url.pathname.slice(1); + + if (!topic || topic.includes('/')) { + return new Response('Invalid topic', { status: 400 }); + } + + const body = await req.text(); + if (!body) { + return new Response('Message required', { status: 400 }); + } + + const title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t'); + + const topicKey = `ntfy-${topic}`; + let groupId = getGroupId(topicKey); + + if (!groupId) { + groupId = await createGroup(topic); + register(topicKey, groupId, topic); + logVerbose(`Created new group for ntfy topic: ${topic}`); + } + + const message = title ? `**${title}**\n${body}` : body; + + await sendGroupMessage(groupId, message); + + logVerbose(`Sent ntfy message to topic ${topic}: ${title || body.substring(0, 50)}`); + + return new Response( + JSON.stringify({ + id: Date.now().toString(), + time: Math.floor(Date.now() / 1000), + event: 'message', + topic, + message: body, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } catch (error) { + logError('Failed to handle ntfy publish:', error); + return new Response('Internal server error', { status: 500 }); + } +}; + +export const ntfyRoutes = { + '/:topic': { + POST: withAuth(handleNtfyPublish), + }, +}; diff --git a/server/routes/unifiedpush.ts b/server/routes/unifiedpush.ts index de086be..9e6b1d3 100644 --- a/server/routes/unifiedpush.ts +++ b/server/routes/unifiedpush.ts @@ -1,4 +1,3 @@ -import { ROUTES } from '@/constants/server'; import { createGroup, sendGroupMessage } from '@/modules/signal'; import { getGroupId, register, remove } from '@/modules/store'; import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush'; @@ -34,7 +33,7 @@ const handleRegister = async (req: Request) => { const proto = req.headers.get('x-forwarded-proto') || 'http'; const host = req.headers.get('host') || 'localhost:8080'; const baseUrl = `${proto}://${host}`; - const endpoint = `${baseUrl}${ROUTES.MATRIX_NOTIFY}/${endpointId}`; + const endpoint = `${baseUrl}/_matrix/push/v1/notify/${endpointId}`; return Response.json({ endpoint, gateway: 'matrix' }); }; @@ -53,15 +52,15 @@ const handleDiscovery = () => }); export const unifiedPushRoutes = { - [ROUTES.UP]: { + '/up': { GET: handleDiscovery, }, - [ROUTES.MATRIX_NOTIFY]: { + '/_matrix/push/v1/notify': { POST: handleMatrixNotify, }, - [ROUTES.UP_INSTANCE]: { + '/up/:instance': { POST: handleRegister, DELETE: handleUnregister, }, diff --git a/server/utils/auth.ts b/server/utils/auth.ts index f1e0bb7..4f7464c 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,10 +1,58 @@ +import { timingSafeEqual } from 'node:crypto'; import { API_KEY } from '@/constants/config'; +import { logWarn } from '@/utils/log'; + +// Rate limiing: track failed auth attempts per IP +const failedAttempts = new Map(); +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes + +// Cloudflare Tunnel sends real IP in CF-Connecting-IP +const getClientIP = (req: Request) => + req.headers.get('cf-connecting-ip') || + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + req.headers.get('x-real-ip') || + 'unknown'; + +const isRateLimited = (ip: string) => { + const now = Date.now(); + const record = failedAttempts.get(ip); + + if (!record) return false; + + if (now > record.resetAt) { + failedAttempts.delete(ip); + return false; + } + + return record.count >= MAX_FAILED_ATTEMPTS; +}; + +const recordFailedAttempt = (ip: string) => { + const now = Date.now(); + const record = failedAttempts.get(ip); + + if (!record || now > record.resetAt) { + failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION }); + } else { + record.count++; + if (record.count >= MAX_FAILED_ATTEMPTS) { + logWarn(`IP ${ip} locked out after ${MAX_FAILED_ATTEMPTS} failed auth attempts`); + } + } +}; const checkAuth = (req: Request) => { if (!API_KEY) { return new Response('Unauthorized', { status: 401 }); } + const clientIP = getClientIP(req); + + if (isRateLimited(clientIP)) { + return new Response('Too many failed attempts. Try again later.', { status: 429 }); + } + const proto = req.headers.get('x-forwarded-proto') || 'http'; const host = req.headers.get('host') || ''; const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1'); @@ -13,10 +61,28 @@ const checkAuth = (req: Request) => { return new Response('HTTPS required when API_KEY is configured', { status: 403 }); } - if (req.headers.get('authorization') !== `Bearer ${API_KEY}`) { + const authHeader = req.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + recordFailedAttempt(clientIP); return new Response(null, { status: 401 }); } + const providedKey = authHeader.slice(7); + + if (providedKey.length !== API_KEY.length) { + recordFailedAttempt(clientIP); + return new Response(null, { status: 401 }); + } + + const providedBuffer = Buffer.from(providedKey); + const keyBuffer = Buffer.from(API_KEY); + + if (!timingSafeEqual(providedBuffer, keyBuffer)) { + recordFailedAttempt(clientIP); + return new Response(null, { status: 401 }); + } + + failedAttempts.delete(clientIP); return null; };