diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95fec53..55bc67f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.7 + bun-version: 1.3.8 - name: Install dependencies run: bun install && cd server && bun install diff --git a/README.md b/README.md index 4191662..a03d6b4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # SUP -**Push notification system using Signal as transport** +**Self-hosted notification gateway using Signal and Webhooks for transport** [Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture) @@ -12,11 +12,16 @@ -SUP is a self-hosted server that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to any network observers. All notification traffic appears as regular Signal messages. +SUP is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or custom webhooks. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own webhook endpoints for custom handling. ## How? -SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli). +SUP accepts notifications via HTTP POST requests and routes them based on your configured delivery method: + +- **Signal groups**: Uses [signal-cli](https://github.com/AsamK/signal-cli) to create a Signal group for each app and send notifications as messages +- **Webhook forwarding**: Forwards notifications to your own webhook URL (useful for UnifiedPush distributors, ntfy, or custom handlers) + +Each endpoint can be independently configured to use either delivery method through the admin UI. For the optional Proton Mail integration, SUP requires a server that runs Proton's official [proton-bridge](https://github.com/ProtonMail/proton-bridge). SUP's docker compose process will run an image from [protonmail-bridge-docker](https://github.com/shenxn/protonmail-bridge-docker). Once authenticated, the communication between SUP and proton-bridge will be over IMAP. @@ -170,7 +175,7 @@ Add the Base64 version of your API_KEY environment variable secret to your secre sup_basic_auth: "Basic " ``` -Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify sup action. +Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify sup action. ## Monitoring diff --git a/bun.lock b/bun.lock index 8c04743..8247563 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.13", - "@types/bun": "1.3.7", + "@types/bun": "1.3.8", "@types/imap": "0.8.43", "typescript": "5.9.3", }, @@ -37,13 +37,13 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], - "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/imap": ["@types/imap@0.8.43", "", { "dependencies": { "@types/node": "*" } }, "sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow=="], "@types/node": ["@types/node@25.0.7", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w=="], - "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c34d29b..2086537 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,6 @@ services: server: + container_name: sup-server build: context: . dockerfile: server/Dockerfile @@ -21,6 +22,7 @@ services: restart: unless-stopped protonmail-bridge: + container_name: protonmail-bridge image: shenxn/protonmail-bridge:build profiles: ['protonmail'] ports: diff --git a/docker-compose.yml b/docker-compose.yml index 3c8abe6..46f152c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: server: + container_name: sup-server image: ghcr.io/lone-cloud/sup:latest ports: - '8080:8080' @@ -20,6 +21,7 @@ services: restart: unless-stopped protonmail-bridge: + container_name: protonmail-bridge image: shenxn/protonmail-bridge:build profiles: ['protonmail'] volumes: diff --git a/package.json b/package.json index b06f60e..73d1934 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.13", - "@types/bun": "1.3.7", + "@types/bun": "1.3.8", "@types/imap": "0.8.43", "typescript": "5.9.3" } diff --git a/server/Dockerfile b/server/Dockerfile index 56c0d98..7eaf1dc 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.7-debian AS builder +FROM oven/bun:1.3.8-debian AS builder WORKDIR /app diff --git a/server/index.ts b/server/index.ts index 9c58467..0a67601 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,7 +17,7 @@ import { cleanupDaemon, initSignal } from '@/modules/signal'; import { admin } from '@/routes/admin'; import { ntfy } from '@/routes/ntfy'; import { protonMail } from '@/routes/proton-mail'; -import { unifiedPush } from '@/routes/unified-push'; +import { webhook } from '@/routes/webhook'; import { getLanIP, isLocalIP } from '@/utils/auth'; import { formatToCspString } from '@/utils/format'; import { logError, logInfo, logVerbose, logWarn } from '@/utils/log'; @@ -82,8 +82,8 @@ app.use('*', serveStatic({ root: PUBLIC_DIR })); app.route('/', ntfy); app.route('/', admin); -app.route('/api', protonMail); -app.route('/api', unifiedPush); +app.route('/api/proton-mail', protonMail); +app.route('/api/webhook', webhook); app.notFound((c) => c.text('Not Found', 404)); diff --git a/server/modules/notifications.ts b/server/modules/notifications.ts index c80d1bc..d0a078d 100644 --- a/server/modules/notifications.ts +++ b/server/modules/notifications.ts @@ -1,6 +1,6 @@ import { createGroup, sendGroupMessage } from '@/modules/signal'; import { getMapping, register } from '@/modules/store'; -import { sendUnifiedPushNotification } from '@/modules/unified-push'; +import { sendWebhookNotification } from '@/modules/webhook'; import type { Notification } from '@/types/notifications'; import { logError, logVerbose } from '@/utils/log'; @@ -22,13 +22,13 @@ export const sendNotification = async (endpoint: string, notification: Notificat const { channel, upEndpoint, appName } = mapping; let { groupId } = mapping; - if (channel === 'unifiedpush') { + if (channel === 'webhook') { if (!upEndpoint) { - logError(`UnifiedPush endpoint not configured for ${appName}`); + logError(`Webhook endpoint not configured for ${appName}`); return false; } - return await sendUnifiedPushNotification(upEndpoint, notification); + return await sendWebhookNotification(upEndpoint, notification); } if (!groupId) { diff --git a/server/modules/store.ts b/server/modules/store.ts index a2de250..99b266b 100644 --- a/server/modules/store.ts +++ b/server/modules/store.ts @@ -31,7 +31,7 @@ export function register( export function register( endpoint: string, appName: string, - channel: 'unifiedpush', + channel: 'webhook', options: { upEndpoint: string }, ): void; export function register( diff --git a/server/modules/unified-push.ts b/server/modules/webhook.ts similarity index 62% rename from server/modules/unified-push.ts rename to server/modules/webhook.ts index 666baf1..f691a01 100644 --- a/server/modules/unified-push.ts +++ b/server/modules/webhook.ts @@ -1,7 +1,7 @@ import type { Notification } from '@/types/notifications'; import { logError, logVerbose } from '@/utils/log'; -export const sendUnifiedPushNotification = async (endpoint: string, notification: Notification) => { +export const sendWebhookNotification = async (endpoint: string, notification: Notification) => { try { const payload = { message: notification.message, @@ -18,15 +18,17 @@ export const sendUnifiedPushNotification = async (endpoint: string, notification }); if (!response.ok) { - logError(`Failed to send UP notification: ${response.status} ${response.statusText}`); + logError(`Failed to send webhook notification: ${response.status} ${response.statusText}`); return false; } - logVerbose(`Sent UP notification to ${endpoint}: ${notification.message.substring(0, 50)}`); + logVerbose( + `Sent webhook notification to ${endpoint}: ${notification.message.substring(0, 50)}`, + ); return true; } catch (error) { - logError('Failed to send UP notification:', error); + logError('Failed to send webhook notification:', error); return false; } }; diff --git a/server/public/index.css b/server/public/index.css index 3f57ad1..3ad93d3 100644 --- a/server/public/index.css +++ b/server/public/index.css @@ -8,12 +8,12 @@ --success: #28a745; --error: #dc3545; --spinner-track: #e0e0e0; - --badge-signal-bg: rgba(129, 89, 184, 0.2); - --badge-up-bg: rgba(40, 167, 69, 0.2); + --badge-signal-bg: #8159b8; + --badge-webhook-bg: rgba(40, 167, 69, 0.2); } @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --text-primary: #e0e0e0; @@ -23,23 +23,10 @@ --error: #ef4444; --spinner-track: #4a4a4a; --badge-signal-bg: #8159b8; - --badge-up-bg: #28a745; + --badge-webhook-bg: #28a745; } } -[data-theme="light"] { - --bg-primary: #f5f5f5; - --bg-secondary: #fdfdfd; - --text-primary: #000; - --text-secondary: #666; - --border-color: rgba(0, 0, 0, 0.1); - --success: #28a745; - --error: #dc3545; - --spinner-track: #e0e0e0; - --badge-signal-bg: rgba(129, 89, 184, 0.2); - --badge-up-bg: rgba(40, 167, 69, 0.2); -} - [data-theme="dark"] { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; @@ -50,7 +37,7 @@ --error: #ef4444; --spinner-track: #4a4a4a; --badge-signal-bg: #8159b8; - --badge-up-bg: #28a745; + --badge-webhook-bg: #28a745; } /* CSS Reset */ @@ -249,8 +236,8 @@ h2 { color: white; } -.channel-unifiedpush { - background: var(--badge-up-bg); +.channel-webhook { + background: var(--badge-webhook-bg); color: white; } diff --git a/server/public/index.html b/server/public/index.html index f178401..c4f0dc4 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -17,8 +17,7 @@
+ hx-trigger="load">
@@ -45,8 +44,7 @@

Endpoints

+ hx-trigger="load">
diff --git a/server/public/manifest.json b/server/public/manifest.json index 2dcfb18..d89df99 100644 --- a/server/public/manifest.json +++ b/server/public/manifest.json @@ -1,7 +1,7 @@ { "name": "SUP Admin", "short_name": "SUP", - "description": "Signal Unified Push Admin Panel", + "description": "Notification gateway admin panel", "start_url": "/", "display": "standalone", "background_color": "#1a1a1a", diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 89c5653..65dfb89 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -51,7 +51,7 @@ admin.get('/api/health', async (c) => { }); admin.get('/fragment/health', async (c) => { - const { html } = await handleHealthFragment(); + const html = await handleHealthFragment(); return c.html(html); }); @@ -84,7 +84,7 @@ admin.post('/action/toggle-channel', async (c) => { return c.json({ error: 'Invalid endpoint' }, 400); } - if (!channel || !['signal', 'unifiedpush'].includes(channel)) { + if (!channel || !['signal', 'webhook'].includes(channel)) { return c.json({ error: 'Invalid channel' }, 400); } @@ -125,7 +125,7 @@ const handleHealthFragment = async () => {
`; - return { html, linked }; + return html; }; const handleSignalInfoFragment = async () => { @@ -156,16 +156,20 @@ const handleEndpointsFragment = async () => { return `
    ${endpoints - .map( - (e) => ` + .map((e) => { + const isSignal = e.channel === 'signal'; + const isWebhook = e.channel === 'webhook'; + + return `
  • ${e.appName}
    - ${e.channel === 'signal' ? 'Signal' : 'UnifiedPush'} - ${e.upEndpoint ? `${new URL(e.upEndpoint).hostname}` : ''} + ${isSignal ? 'Signal' : 'Webhook'} + ${e.upEndpoint && isWebhook ? `${new URL(e.upEndpoint).hostname}` : ``} + ${e.groupId && isSignal ? `${e.groupId}` : ``}
    @@ -182,8 +186,8 @@ const handleEndpointsFragment = async () => { hx-swap="innerHTML" hx-include="closest form" > - - + + ` @@ -201,8 +205,8 @@ const handleEndpointsFragment = async () => {
  • - `, - ) + `; + }) .join('')}
`; diff --git a/server/routes/proton-mail.ts b/server/routes/proton-mail.ts index f7aa027..da90df1 100644 --- a/server/routes/proton-mail.ts +++ b/server/routes/proton-mail.ts @@ -14,7 +14,7 @@ protonMail.use( }), ); -protonMail.post('/proton-mail/mark-read', async (c) => { +protonMail.post('/mark-read', async (c) => { try { const body = await c.req.json(); const { uid } = body; diff --git a/server/routes/unified-push.ts b/server/routes/webhook.ts similarity index 70% rename from server/routes/unified-push.ts rename to server/routes/webhook.ts index da55c90..49c98b8 100644 --- a/server/routes/unified-push.ts +++ b/server/routes/webhook.ts @@ -5,17 +5,17 @@ import { register } from '@/modules/store'; import { verifyApiKey } from '@/utils/auth'; import { logError, logVerbose } from '@/utils/log'; -export const unifiedPush = new Hono(); +export const webhook = new Hono(); -unifiedPush.use( +webhook.use( '*', basicAuth({ verifyUser: (_, password) => verifyApiKey(password), - realm: 'SUP UnifiedPush - Username: any, Password: API_KEY', + realm: 'SUP Webhook - Username: any, Password: API_KEY', }), ); -unifiedPush.post('/up/register', async (c) => { +webhook.post('/register', async (c) => { try { const body = await c.req.json<{ appName: string; @@ -36,17 +36,17 @@ unifiedPush.post('/up/register', async (c) => { const endpoint = `${ENDPOINT_PREFIX_UP}${appName}`; - register(endpoint, appName, 'unifiedpush', { upEndpoint }); + register(endpoint, appName, 'webhook', { upEndpoint }); - logVerbose(`Registered UP endpoint for ${appName}: ${upEndpoint}`); + logVerbose(`Registered webhook endpoint for ${appName}: ${upEndpoint}`); return c.json({ endpoint, appName, - channel: 'unifiedpush', + channel: 'webhook', }); } catch (error) { - logError('Failed to register UP endpoint:', error); + logError('Failed to register webhook endpoint:', error); return c.json({ error: 'Internal server error' }, 500); } diff --git a/server/types/notifications.d.ts b/server/types/notifications.d.ts index 3eb25f5..6da3afe 100644 --- a/server/types/notifications.d.ts +++ b/server/types/notifications.d.ts @@ -1,4 +1,4 @@ -export interface UnifiedPushAction { +export interface WebPushAction { id: string; endpoint: string; method: 'POST' | 'GET' | 'DELETE'; @@ -8,7 +8,7 @@ export interface UnifiedPushAction { export interface Notification { title?: string; message: string; - actions?: UnifiedPushAction[]; + actions?: WebPushAction[]; } -export type NotificationChannel = 'signal' | 'unifiedpush'; +export type NotificationChannel = 'signal' | 'webhook';