clean code, improve readme

This commit is contained in:
lone-cloud 2026-01-18 12:55:12 -08:00
parent c6f702098d
commit a6a8653455
8 changed files with 105 additions and 336 deletions

View file

@ -1,3 +1,7 @@
# Optional: Server port
# Default: 8080
# PORT=8080
# Optional: Protect endpoint registration with an API key (recommended for public deployments) # Optional: Protect endpoint registration with an API key (recommended for public deployments)
# Default: unset (no authentication required) # Default: unset (no authentication required)
# API_KEY=your-secret-key-here # API_KEY=your-secret-key-here
@ -14,6 +18,7 @@
# PROTON_BRIDGE_HOST=protonmail-bridge # PROTON_BRIDGE_HOST=protonmail-bridge
# PROTON_BRIDGE_PORT=143 # PROTON_BRIDGE_PORT=143
# SUP_TOPIC=Proton Mail # SUP_TOPIC=Proton Mail
# Optional: Enable Android app integration for ProtonMail notifications # Optional: Enable Android app integration for ProtonMail notifications
# When enabled, sends special format that SUP Android app intercepts to show custom notifications # When enabled, sends special format that SUP Android app intercepts to show custom notifications
# that open ProtonMail app on click. When disabled, ProtonMail notifications appear as plain Signal messages. # that open ProtonMail app on click. When disabled, ProtonMail notifications appear as plain Signal messages.

View file

@ -1,8 +1,17 @@
# SUP (Signal Unified Push) <div align="center">
Privacy-preserving push notifications using Signal as transport. <img src="assets/sup.webp" alt="SUP Icon" width="80" height="80" />
## What is SUP? # SUP
**SUP (Signal Unified Push) is a privacy-preserving push notifications using Signal as transport**
[Setup](#setup) • [Architecture](#architecture)
</div>
<!-- markdownlint-enable MD033 -->
SUP is a UnifiedPush distributor that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to your ISP or network observers. All notification traffic appears as regular Signal messages. SUP is a UnifiedPush distributor that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to your ISP or network observers. All notification traffic appears as regular Signal messages.
@ -12,9 +21,17 @@ Traditional push notification systems (ntfy, FCM) require persistent WebSocket c
## Setup ## Setup
**⚠️ DOCKER COMPOSE REQUIRED**: The services must be deployed together using `docker compose`. Running individual Dockerfiles separately is not supported and will compromise security. ### 1. Install Android App
### Quick Start with Docker Compose Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup/releases).
**Certificate Fingerprint:**
```text
0D:3C:99:15:0E:12:1A:DE:0D:AE:05:CB:16:46:5E:65:31:56:DC:D6:98:87:59:4E:79:B1:0D:AE:1E:56:F2:E8
```
### 2. Start SUP Server with Docker Compose on a self-hosted server
**Without ProtonMail** (just UnifiedPush): **Without ProtonMail** (just UnifiedPush):
@ -22,18 +39,12 @@ Traditional push notification systems (ntfy, FCM) require persistent WebSocket c
# Download docker-compose.yml # Download docker-compose.yml
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compose.yml curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compose.yml
# Create .env file # Download .env.example (optional)
cat > .env << 'EOF' curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/.env.example
# Optional: API key for remote access
# Set this to protect your server when accessing it from outside your home network
# (e.g., registering UnifiedPush apps while away from home)
# Default: unset (no authentication required)
API_KEY=your-random-secret-key-here
# Optional: Enable verbose logging # Configure (optional)
# Default: false cp .env.example .env
VERBOSE=false nano .env
EOF
# Start SUP server # Start SUP server
docker compose up -d docker compose up -d
@ -42,7 +53,7 @@ docker compose up -d
# Visit http://localhost:8080/link and scan QR code with Signal app # Visit http://localhost:8080/link and scan QR code with Signal app
``` ```
### ProtonMail Integration (Optional) ### 3. ProtonMail Integration (Optional)
To receive ProtonMail notifications via Signal: To receive ProtonMail notifications via Signal:
@ -68,7 +79,7 @@ To receive ProtonMail notifications via Signal:
```bash ```bash
# Add these to your .env file # Add these to your .env file
BRIDGE_IMAP_USERNAME=your-email@proton.me BRIDGE_IMAP_USERNAME=bridge-username-from-info-command
BRIDGE_IMAP_PASSWORD=bridge-generated-password-from-info-command BRIDGE_IMAP_PASSWORD=bridge-generated-password-from-info-command
``` ```
@ -80,17 +91,6 @@ To receive ProtonMail notifications via Signal:
Your phone will now receive Signal notifications when ProtonMail receives new emails. Your phone will now receive Signal notifications when ProtonMail receives new emails.
#### ProtonMail Android App Integration (Optional)
If you have the ProtonMail Android app installed, you can enable integration so that clicking on email notifications opens the ProtonMail app directly:
```bash
# Add this to your .env file
ENABLE_PROTON_ANDROID=true
```
When enabled, the SUP Android app will intercept email notifications and show them as custom notifications that launch the ProtonMail app on click. When disabled, email notifications appear as regular Signal messages.
### Development ### Development
For local development, install Bun and signal-cli: For local development, install Bun and signal-cli:
@ -118,18 +118,6 @@ bun install
bun --filter sup-server dev bun --filter sup-server dev
``` ```
## Android App
Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup/releases).
**Install via Obtainium:** [obtainium://add/https://github.com/lone-cloud/sup](obtainium://add/https://github.com/lone-cloud/sup)
**Certificate Fingerprint:**
```text
0D:3C:99:15:0E:12:1A:DE:0D:AE:05:CB:16:46:5E:65:31:56:DC:D6:98:87:59:4E:79:B1:0D:AE:1E:56:F2:E8
```
## Architecture ## Architecture
![SUP Architecture](assets/SUP%20Architecture.webp) ![SUP Architecture](assets/SUP%20Architecture.webp)

View file

@ -984,197 +984,6 @@
"autoResize": true, "autoResize": true,
"lineHeight": 1.25 "lineHeight": 1.25
}, },
{
"id": "8cvm889GbAY8ittddtEJB",
"type": "rectangle",
"x": 612.4082539876304,
"y": 24.463267008463504,
"width": 166.41015625,
"height": 133.2421875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "ar",
"roundness": {
"type": 3
},
"seed": 1821620749,
"version": 425,
"versionNonce": 990282635,
"isDeleted": true,
"boundElements": [
{
"id": "Lbesiv2QZE4qMpdejIPzY",
"type": "arrow"
},
{
"id": "RNCrlFh5EZBwnYr-7bA_j",
"type": "arrow"
}
],
"updated": 1768722055737,
"link": null,
"locked": false
},
{
"id": "VFsafA6rMCSAeOHHPzZuL",
"type": "text",
"x": 630.0674293154764,
"y": 52.289853050595184,
"width": 139.55943298339844,
"height": 75,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "at",
"roundness": null,
"seed": 210388483,
"version": 182,
"versionNonce": 738978149,
"isDeleted": true,
"boundElements": [
{
"id": "RNCrlFh5EZBwnYr-7bA_j",
"type": "arrow"
}
],
"updated": 1768722056624,
"link": null,
"locked": false,
"text": "ProtonMail\nBridge\n(Optional)",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "ProtonMail\nBridge\n(Optional)",
"autoResize": false,
"lineHeight": 1.25
},
{
"id": "Lbesiv2QZE4qMpdejIPzY",
"type": "arrow",
"x": 623.8659261067706,
"y": 56.516159369287976,
"width": 139.52478027343722,
"height": 1.795223729407006,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "b00",
"roundness": null,
"seed": 669405859,
"version": 147,
"versionNonce": 1201807467,
"isDeleted": true,
"boundElements": [
{
"type": "text",
"id": "A2at5s2ss9zqWtu77JXsW"
}
],
"updated": 1768722096436,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
-69.76239013671864,
0
],
[
-69.76239013671864,
-1.795223729407006
],
[
-139.52478027343722,
-1.795223729407006
]
],
"startBinding": {
"elementId": "l68p6MrICi0j1MxyTO7FJ",
"mode": "orbit",
"fixedPoint": [
-0.03605549165512547,
0.33691893735853506
]
},
"endBinding": {
"elementId": "ubnB-MCtd3Y84dUGFa-b7",
"mode": "orbit",
"fixedPoint": [
1.036055491655126,
0.2969845457972111
]
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true,
"fixedSegments": null,
"startIsSpecial": null,
"endIsSpecial": null
},
{
"id": "A2at5s2ss9zqWtu77JXsW",
"type": "text",
"x": 515.2700388574564,
"y": 21.91319036172733,
"width": 61.11991882324219,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "b01",
"roundness": null,
"seed": 1606941325,
"version": 9,
"versionNonce": 324972485,
"isDeleted": true,
"boundElements": [],
"updated": 1768722096436,
"link": null,
"locked": false,
"text": "Notify",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "Lbesiv2QZE4qMpdejIPzY",
"originalText": "Notify",
"autoResize": true,
"lineHeight": 1.25
},
{ {
"id": "pDL0AmXVDpmeLiMPNhgXB", "id": "pDL0AmXVDpmeLiMPNhgXB",
"type": "ellipse", "type": "ellipse",
@ -2640,10 +2449,10 @@
{ {
"id": "KsN6VJCu_Js44TB79mnZV", "id": "KsN6VJCu_Js44TB79mnZV",
"type": "text", "type": "text",
"x": 719.617652529762, "x": 759.0713678023156,
"y": 551.1148158482142, "y": 546.2597602123432,
"width": 392.6328125, "width": 392.6328125,
"height": 275, "height": 250,
"angle": 0, "angle": 0,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"backgroundColor": "transparent", "backgroundColor": "transparent",
@ -2657,20 +2466,20 @@
"index": "b0Z", "index": "b0Z",
"roundness": null, "roundness": null,
"seed": 302706755, "seed": 302706755,
"version": 328, "version": 383,
"versionNonce": 1529226149, "versionNonce": 310610591,
"isDeleted": false, "isDeleted": false,
"boundElements": [], "boundElements": [],
"updated": 1768724761730, "updated": 1768763155267,
"link": null, "link": null,
"locked": false, "locked": false,
"text": "signal-cli:\nhttps://github.com/AsamK/signal-cli\n\nprotonmail bridge:\nhttps://github.com/ProtonMail/proton-\nbridge\n\nprotonmail bridge docker image:\nhttps://github.com/shenxn/protonmail-\nbridge-docker\n", "text": "signal-cli: \ngithub.com/AsamK/signal-cli\n\nprotonmail bridge:\ngithub.com/ProtonMail/proton-bridge\n\nprotonmail bridge docker image:\ngithub.com/shenxn/protonmail-bridge-\ndocker\n",
"fontSize": 20, "fontSize": 20,
"fontFamily": 5, "fontFamily": 5,
"textAlign": "left", "textAlign": "left",
"verticalAlign": "top", "verticalAlign": "top",
"containerId": null, "containerId": null,
"originalText": "signal-cli: https://github.com/AsamK/signal-cli\n\nprotonmail bridge: https://github.com/ProtonMail/proton-bridge\n\nprotonmail bridge docker image: https://github.com/shenxn/protonmail-bridge-docker\n", "originalText": "signal-cli: \ngithub.com/AsamK/signal-cli\n\nprotonmail bridge: github.com/ProtonMail/proton-bridge\n\nprotonmail bridge docker image: github.com/shenxn/protonmail-bridge-docker\n",
"autoResize": false, "autoResize": false,
"lineHeight": 1.25 "lineHeight": 1.25
}, },
@ -2838,7 +2647,7 @@
"version": 6, "version": 6,
"versionNonce": 1252528491, "versionNonce": 1252528491,
"isDeleted": false, "isDeleted": false,
"boundElements": null, "boundElements": [],
"updated": 1768724671332, "updated": 1768724671332,
"link": null, "link": null,
"locked": false, "locked": false,
@ -2853,11 +2662,11 @@
"lineHeight": 1.25 "lineHeight": 1.25
}, },
{ {
"id": "1PI-IqUd4I8EvRfkkzFlI", "id": "Yu4GTjs9vKLTNlA076t3-",
"type": "text", "type": "text",
"x": 886.7487327938989, "x": 551.856965948593,
"y": 65.70355980282736, "y": 625.0930346523853,
"width": 8, "width": 78.7799072265625,
"height": 25, "height": 25,
"angle": 0, "angle": 0,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@ -2871,21 +2680,21 @@
"frameId": null, "frameId": null,
"index": "b0d", "index": "b0d",
"roundness": null, "roundness": null,
"seed": 1872296133, "seed": 74100447,
"version": 3, "version": 4,
"versionNonce": 878177861, "versionNonce": 1570293599,
"isDeleted": true, "isDeleted": true,
"boundElements": null, "boundElements": null,
"updated": 1768722227703, "updated": 1768763176302,
"link": null, "link": null,
"locked": false, "locked": false,
"text": "", "text": "https://",
"fontSize": 20, "fontSize": 20,
"fontFamily": 5, "fontFamily": 5,
"textAlign": "center", "textAlign": "left",
"verticalAlign": "middle", "verticalAlign": "top",
"containerId": "arkYz5xpoKLpnfgkI-VOo", "containerId": null,
"originalText": "", "originalText": "https://",
"autoResize": true, "autoResize": true,
"lineHeight": 1.25 "lineHeight": 1.25
} }

View file

@ -13,12 +13,6 @@ export const ROUTES = {
TOPICS: '/topics', TOPICS: '/topics',
} as const; } as const;
export const CONTENT_TYPE = {
HTML: 'text/html',
JSON: 'application/json',
TEXT: 'text/plain',
} as const;
export const TEMPLATES = { export const TEMPLATES = {
LINKED: 'templates/linked.html', LINKED: 'templates/linked.html',
LINK: 'templates/link.html', LINK: 'templates/link.html',

View file

@ -1,7 +1,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config'; import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config';
import { CONTENT_TYPE, ROUTES, TEMPLATES } from './constants/server'; import { ROUTES } from './constants/server';
import { checkSignalCli, hasValidAccount, initSignal, startDaemon } from './modules/signal'; import { checkSignalCli, initSignal, startDaemon } from './modules/signal';
import { handleHealth } from './routes/health'; import { handleHealth } from './routes/health';
import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link'; import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link';
import { handleNotify, handleTopics } from './routes/notify'; import { handleNotify, handleTopics } from './routes/notify';
@ -12,7 +12,7 @@ import {
handleRegister, handleRegister,
handleUnregister, handleUnregister,
} from './routes/unifiedpush'; } from './routes/unifiedpush';
import { checkAuth } from './utils/auth'; import { withAuth, withFormAuth } from './utils/auth';
let daemon: ReturnType<typeof Bun.spawn> | null = null; let daemon: ReturnType<typeof Bun.spawn> | null = null;
@ -60,13 +60,12 @@ const server = Bun.serve({
}, },
[ROUTES.LINK_UNLINK]: { [ROUTES.LINK_UNLINK]: {
POST: async (req) => { POST: withFormAuth(() =>
const response = await handleUnlink(req, daemon); handleUnlink(async () => {
if (response.status === 303) { daemon?.kill();
daemon = await startDaemon(); daemon = await startDaemon();
} }),
return response; ),
},
}, },
[ROUTES.UP]: { [ROUTES.UP]: {
@ -74,19 +73,11 @@ const server = Bun.serve({
}, },
[ROUTES.ENDPOINTS]: { [ROUTES.ENDPOINTS]: {
GET: (req) => { GET: withAuth(handleEndpoints),
const auth = checkAuth(req);
if (auth) return auth;
return handleEndpoints();
},
}, },
[ROUTES.TOPICS]: { [ROUTES.TOPICS]: {
GET: (req) => { GET: withAuth(handleTopics),
const auth = checkAuth(req);
if (auth) return auth;
return handleTopics();
},
}, },
[ROUTES.MATRIX_NOTIFY]: { [ROUTES.MATRIX_NOTIFY]: {
@ -94,35 +85,14 @@ const server = Bun.serve({
}, },
[ROUTES.UP_INSTANCE]: { [ROUTES.UP_INSTANCE]: {
POST: (req) => { POST: withAuth((req) => handleRegister(req, new URL(req.url))),
const auth = checkAuth(req); DELETE: withAuth((req) => handleUnregister(new URL(req.url))),
if (auth) return auth;
return handleRegister(req, new URL(req.url));
},
DELETE: (req) => {
const auth = checkAuth(req);
if (auth) return auth;
return handleUnregister(new URL(req.url));
},
}, },
[ROUTES.NOTIFY_TOPIC]: { [ROUTES.NOTIFY_TOPIC]: {
POST: (req) => { POST: withAuth((req) => handleNotify(req, new URL(req.url))),
const auth = checkAuth(req);
if (auth) return auth;
return handleNotify(req, new URL(req.url));
},
}, },
}, },
async fetch(_req) {
if (!(await hasValidAccount())) {
const html = await Bun.file(TEMPLATES.SETUP).text();
return new Response(html, { headers: { 'content-type': CONTENT_TYPE.HTML } });
}
return new Response(null, { status: 404 });
},
}); });
console.log(chalk.cyan.bold(`\n🚀 SUP running on http://localhost:${server.port}`)); console.log(chalk.cyan.bold(`\n🚀 SUP running on http://localhost:${server.port}`));

View file

@ -1,5 +1,5 @@
import { API_KEY } from '../constants/config'; import { API_KEY } from '../constants/config';
import { CONTENT_TYPE, ROUTES, TEMPLATES } from '../constants/server'; import { ROUTES, TEMPLATES } from '../constants/server';
import { import {
finishLink, finishLink,
generateLinkQR, generateLinkQR,
@ -22,14 +22,14 @@ export const handleLink = async () => {
} }
return new Response(html, { return new Response(html, {
headers: { 'content-type': CONTENT_TYPE.HTML }, headers: { 'content-type': 'text/html' },
}); });
}; };
export const handleLinkQR = async () => { export const handleLinkQR = async () => {
const qrDataUrl = await generateLinkQR(); const qrDataUrl = await generateLinkQR();
return new Response(qrDataUrl, { return new Response(qrDataUrl, {
headers: { 'content-type': CONTENT_TYPE.TEXT }, headers: { 'content-type': 'text/plain' },
}); });
}; };
@ -47,24 +47,9 @@ export const handleLinkStatus = async () => {
return Response.json({ linked }); return Response.json({ linked });
}; };
export const handleUnlink = async (req: Request, daemon: ReturnType<typeof Bun.spawn> | null) => { export const handleUnlink = async (killAndRestart: () => Promise<void>) => {
if (API_KEY) {
const body = await req.text();
const params = new URLSearchParams(body);
const password = params.get('password');
if (password !== API_KEY) {
return new Response(null, { status: 403 });
}
}
await unlinkDevice(); await unlinkDevice();
await killAndRestart();
if (daemon) {
daemon.kill();
}
await new Promise((resolve) => setTimeout(resolve, 500));
return new Response('', { return new Response('', {
status: 303, status: 303,

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SUP - Setup Required</title>
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
<h1>Setup Required</h1>
<p>Please <a href="/link">link your Signal account</a> to use SUP.</p>
</body>
</html>

View file

@ -1,6 +1,6 @@
import { API_KEY } from '../constants/config'; import { API_KEY } from '../constants/config';
export const checkAuth = (req: Request) => { const checkAuth = (req: Request) => {
if (!API_KEY) return null; if (!API_KEY) return null;
const proto = req.headers.get('x-forwarded-proto') || 'http'; const proto = req.headers.get('x-forwarded-proto') || 'http';
@ -17,3 +17,33 @@ export const checkAuth = (req: Request) => {
return null; return null;
}; };
export const checkFormAuth = async (req: Request) => {
const bearerAuth = checkAuth(req);
if (!bearerAuth) return null;
// Fall back to form password
const body = await req.text();
const params = new URLSearchParams(body);
if (params.get('password') === API_KEY) {
return null;
}
return new Response(null, { status: 403 });
};
export const withAuth =
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
(req: Request, ...args: T) => {
const auth = checkAuth(req);
if (auth) return auth;
return handler(req, ...args);
};
export const withFormAuth =
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
async (req: Request, ...args: T) => {
const auth = await checkFormAuth(req);
if (auth) return auth;
return handler(req, ...args);
};