update to latest bun, better docker container names, dont poll the UI fragments, unifiedpush -> webhook rename

This commit is contained in:
Egor 2026-01-30 15:31:39 -08:00
parent e7a42eeb6c
commit 8d25ab7bf2
18 changed files with 70 additions and 70 deletions

View file

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

View file

@ -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 @@
<!-- markdownlint-enable MD033 -->
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 <Base64 Hash value>"
```
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

View file

@ -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=="],

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
FROM oven/bun:1.3.7-debian AS builder
FROM oven/bun:1.3.8-debian AS builder
WORKDIR /app

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -17,8 +17,7 @@
<div class="card">
<div id="health-status"
hx-get="/fragment/health"
hx-trigger="load, every 10s"
hx-indicator="#health-status">
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>
</div>
@ -45,8 +44,7 @@
<h2>Endpoints</h2>
<div id="endpoints-list"
hx-get="/fragment/endpoints"
hx-trigger="load, every 10s"
hx-indicator="#endpoints-list">
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>
</div>

View file

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

View file

@ -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 () => {
</div>
`;
return { html, linked };
return html;
};
const handleSignalInfoFragment = async () => {
@ -156,16 +156,20 @@ const handleEndpointsFragment = async () => {
return `
<ul class="endpoint-list">
${endpoints
.map(
(e) => `
.map((e) => {
const isSignal = e.channel === 'signal';
const isWebhook = e.channel === 'webhook';
return `
<li class="endpoint-item">
<div class="endpoint-info">
<div class="endpoint-name">
<strong>${e.appName}</strong>
</div>
<div class="endpoint-channel">
<span class="channel-badge channel-${e.channel}">${e.channel === 'signal' ? 'Signal' : 'UnifiedPush'}</span>
${e.upEndpoint ? `<span class="endpoint-detail">${new URL(e.upEndpoint).hostname}</span>` : ''}
<span class="channel-badge channel-${e.channel}">${isSignal ? 'Signal' : 'Webhook'}</span>
${e.upEndpoint && isWebhook ? `<span class="endpoint-detail">${new URL(e.upEndpoint).hostname}</span>` : ``}
${e.groupId && isSignal ? `<span class="endpoint-detail">${e.groupId}</span>` : ``}
</div>
</div>
<div class="endpoint-actions">
@ -182,8 +186,8 @@ const handleEndpointsFragment = async () => {
hx-swap="innerHTML"
hx-include="closest form"
>
<option value="signal" ${e.channel === 'signal' ? 'selected' : ''}>Signal</option>
<option value="unifiedpush" ${e.channel === 'unifiedpush' ? 'selected' : ''}>UnifiedPush</option>
<option value="signal" ${isSignal ? 'selected' : ''}>Signal</option>
<option value="webhook" ${isWebhook ? 'selected' : ''}>Webhook</option>
</select>
</form>
`
@ -201,8 +205,8 @@ const handleEndpointsFragment = async () => {
</form>
</div>
</li>
`,
)
`;
})
.join('')}
</ul>
`;

View file

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

View file

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

View file

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