cleaner routing, need auth for ntfy. more secure auth

This commit is contained in:
lone-cloud 2026-01-20 02:10:44 -08:00
parent 24cccf9740
commit 1db47e4829
9 changed files with 185 additions and 66 deletions

View file

@ -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;

View file

@ -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,
},
});

View file

@ -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');

View file

@ -64,7 +64,7 @@ h2 {
.status {
display: flex;
gap: 10px;
gap: 15px;
flex-wrap: wrap;
}

View file

@ -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'}
</div>
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
IMAP: ${imap ? 'Connected' : 'Disconnected'}
ProtonMail: ${imap ? 'Connected' : 'Disconnected'}
</div>
</div>
`;
@ -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 = `
<p>Scan this QR code with your Signal app:</p>
<p style="font-size: 1.05em;"><strong>Settings Linked Devices Link New Device</strong></p>
@ -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),
},
};

View file

@ -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,
};

60
server/routes/ntfy.ts Normal file
View file

@ -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),
},
};

View file

@ -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,
},

View file

@ -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<string, { count: number; resetAt: number }>();
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;
};