improving README, small refactoring updates

This commit is contained in:
lone-cloud 2026-01-22 11:46:59 -08:00
parent a1fe052a8e
commit 4b801a6353
12 changed files with 199 additions and 45 deletions

View file

@ -14,7 +14,7 @@
> ⚠️ **Early Alpha**: SUP is under rapid development. The Android app is being actively developed and the current version is not thoroughly tested. There are no stable releases yet. Use at your own risk. > ⚠️ **Early Alpha**: SUP is under rapid development. The Android app is being actively developed and the current version is not thoroughly tested. There are no stable releases yet. Use at your own risk.
SUP is a UnifiedPush distributor 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 [UnifiedPush](https://unifiedpush.org/) server and distributor 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.
## Why? ## Why?
@ -24,6 +24,12 @@ SUP also includes an optional Proton Mail integration, allowing you to receive e
Note that you'll need to run SUP on your own server (either at home or on a VPS) since it uses your personal Signal and Proton Mail credentials. A Raspberry Pi, which is a small and affordable micro-computer, works perfectly for this, using minimal power (3-5W) while running SUP 24/7. Note that you'll need to run SUP on your own server (either at home or on a VPS) since it uses your personal Signal and Proton Mail credentials. A Raspberry Pi, which is a small and affordable micro-computer, works perfectly for this, using minimal power (3-5W) while running SUP 24/7.
## How?
SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli).
For the optional ProtonMail 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.
## Setup ## Setup
### 1. Install Android App (Optional) ### 1. Install Android App (Optional)
@ -55,20 +61,20 @@ curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compos
docker compose run --rm protonmail-bridge init docker compose run --rm protonmail-bridge init
``` ```
2. **Login to Proton Mail Bridge**: 2.**Login to Proton Mail Bridge**:
- At the `>>>` prompt, run: `login` - At the `>>>` prompt, run: `login`
- Enter your email - Enter your email
- Enter your password - Enter your password
- Enter your 2FA code - Enter your 2FA code
3. **Get IMAP credentials**: 3.**Get IMAP credentials**:
- Run: `info` - Run: `info`
- Copy the Username and Password shown - Copy the Username and Password shown
- Run: `exit` to quit - Run: `exit` to quit
4. **Add credentials to .env**: 4.**Add credentials to .env**:
```bash ```bash
# Add these to your .env file # Add these to your .env file
@ -76,7 +82,7 @@ PROTON_IMAP_USERNAME=bridge-username-from-info-command
PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command
``` ```
5. **Start all services with Proton Mail**: 5.**Start all services with Proton Mail**:
```bash ```bash
docker compose --profile protonmail up -d docker compose --profile protonmail up -d
@ -84,7 +90,7 @@ docker compose --profile protonmail up -d
Your phone will now receive Signal notifications when Proton Mail receives new emails. Your phone will now receive Signal notifications when Proton Mail receives new emails.
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications. Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
### 3. SUP Server integration ### 3. SUP Server integration
@ -102,10 +108,31 @@ nano .env
# Start SUP server # Start SUP server
docker compose up -d docker compose up -d
# Link your Signal account (one-time setup)
# Visit http://localhost:8080 and scan QR code with Signal app
``` ```
### 4. Configuration
Link your Signal account (one-time setup)
Visit localhost:8080 and scan QR code with Signal app
#### Authenticate with the API_KEY
<img src="assets/screenshots/1.webp" style="display: block" width="500" />
#### Scan the QR code from your Signal app by linking SUP as a device
<img src="assets/screenshots/2.webp" style="display: block" width="500" />
#### A healthy setup with a linked account
<img src="assets/screenshots/3.webp" style="display: block" width="500" />
#### A healthy setup with a linked account and the optional Proton Mail integration
<img src="assets/screenshots/4.webp" style="display: block" width="500" />
### Development ### Development
For local development, install Bun and signal-cli: For local development, install Bun and signal-cli:
@ -118,6 +145,8 @@ git clone https://github.com/lone-cloud/sup.git
cd sup cd sup
bun install bun install
cd server
bun start
``` ```
Then build and run with docker-compose.dev.yml: Then build and run with docker-compose.dev.yml:
@ -132,13 +161,6 @@ or just the proton-bridge:
docker compose -f docker-compose.dev.yml up protonmail-bridge docker compose -f docker-compose.dev.yml up protonmail-bridge
``` ```
Or run services directly with Bun:
```bash
bun install
bun --filter sup-server dev
```
## Real-World Examples ## Real-World Examples
### Proton Mail Notifications ### Proton Mail Notifications
@ -173,7 +195,14 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific
![SUP Architecture](assets/SUP%20Architecture.webp) ![SUP Architecture](assets/SUP%20Architecture.webp)
SUP consists of two services that **MUST run together on the same machine**: SUP consists of two services that **MUST rSUP uses [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) for authentication. un together on the same machin`API_KEY` as a password and the username can be ignored.
For API-based monitoring, you can call `/api/health` which will return JSON health output like:
```JSON
{"uptime":"3s","signal":{"daemon":"running","linked":true},"protonMail":"connected"}
```
**:
- **sup-server** (Bun): Receives webhooks, sends Signal messages via signal-cli. Optional: monitors Proton Mail IMAP - **sup-server** (Bun): Receives webhooks, sends Signal messages via signal-cli. Optional: monitors Proton Mail IMAP
- **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server - **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server

BIN
assets/screenshots/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/screenshots/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assets/screenshots/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/screenshots/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,11 +1,10 @@
# Required: API key for authentication
# API_KEY=your-secret-key-here
# Optional: Server port # Optional: Server port
# Default: 8080 # Default: 8080
# PORT=8080 # PORT=8080
# Optional: Protect endpoint registration with an API key (recommended for public deployments)
# Default: unset (no authentication required)
# API_KEY=your-secret-key-here
# Optional: Allow HTTP connections without HTTPS # Optional: Allow HTTP connections without HTTPS
# Default: false # Default: false
# ALLOW_INSECURE_HTTP=true # ALLOW_INSECURE_HTTP=true
@ -16,7 +15,7 @@
# Optional: Device name shown in Signal app's linked devices list # Optional: Device name shown in Signal app's linked devices list
# Default: SUP # Default: SUP
# DEVICE_NAME=SUP dev # DEVICE_NAME=What SUP
# Optional: Enable verbose logging # Optional: Enable verbose logging
# Default: false # Default: false

View file

@ -11,6 +11,7 @@ import { getOrCreateGroup } from '@/modules/store';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
let imapConnected = false; let imapConnected = false;
let monitorStartTime = 0;
export const isImapConnected = () => imapConnected; export const isImapConnected = () => imapConnected;
@ -68,11 +69,18 @@ export async function startProtonMonitor() {
return; return;
} }
imap.on('mail', async (numNewMsgs: number) => { if (!monitorStartTime) {
logVerbose(`${numNewMsgs} new message(s) received`); monitorStartTime = Date.now();
logVerbose(`Inbox opened with ${box.messages.total} existing messages`);
} else {
logVerbose('Inbox reopened (reconnection)');
}
const fetch = imap.seq.fetch(`${box.messages.total}:*`, { imap.on('mail', async (numNewMsgs: number) => {
bodies: 'HEADER.FIELDS (FROM SUBJECT)', logVerbose(`${numNewMsgs} new message(s) in mailbox`);
const fetch = imap.seq.fetch(`${box.messages.total - numNewMsgs + 1}:*`, {
bodies: 'HEADER.FIELDS (FROM SUBJECT DATE)',
struct: true, struct: true,
}); });
@ -86,6 +94,19 @@ export async function startProtonMonitor() {
const header = Imap.parseHeader(buffer); const header = Imap.parseHeader(buffer);
const rawFrom = header.from?.[0] || 'Unknown sender'; const rawFrom = header.from?.[0] || 'Unknown sender';
const subject = header.subject?.[0] || 'No subject'; const subject = header.subject?.[0] || 'No subject';
const dateStr = header.date?.[0];
if (dateStr) {
const messageDate = new Date(dateStr).getTime();
if (messageDate < monitorStartTime) {
const formattedDate = new Date(dateStr).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
});
logVerbose(`Skipping old email: ${subject} (${formattedDate})`);
return;
}
}
const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/); const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
const from = nameMatch?.[1]?.trim() || rawFrom; const from = nameMatch?.[1]?.trim() || rawFrom;

View file

@ -7,6 +7,7 @@ import {
} from '@/constants/config'; } from '@/constants/config';
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths'; import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types'; import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
import { formatPhoneNumber } from '@/utils/format';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
import { call } from '@/utils/rpc'; import { call } from '@/utils/rpc';
@ -34,7 +35,7 @@ export async function initSignal({ accountOverride }: { accountOverride?: string
const [firstAccount] = result; const [firstAccount] = result;
if (firstAccount) { if (firstAccount) {
account = firstAccount.number; account = firstAccount.number;
logVerbose(`Signal account initialized: ${account}`); logVerbose(`Signal account initialized: ${formatPhoneNumber(account)}`);
logSuccess('Signal account linked'); logSuccess('Signal account linked');
return { linked: true, account }; return { linked: true, account };
} }
@ -267,3 +268,5 @@ export async function startDaemon() {
} }
export const cleanupDaemon = () => daemon?.kill(); export const cleanupDaemon = () => daemon?.kill();
export const getAccount = () => account;

View file

@ -72,6 +72,46 @@ h2 {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-weight: 500; font-weight: 500;
position: relative;
}
.status-item:has(.tooltip) {
cursor: help;
}
.status-item .tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 0.5rem;
background: #333;
color: white;
padding: 0.375rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 400;
white-space: nowrap;
transition: opacity 0.2s;
pointer-events: none;
z-index: 10;
}
.status-item .tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 0.3125rem solid transparent;
border-top-color: #333;
}
.status-item:hover .tooltip {
visibility: visible;
opacity: 1;
} }
.status-ok { .status-ok {
@ -186,6 +226,24 @@ h2 {
.loading { .loading {
color: #666; color: #666;
display: flex;
align-items: center;
gap: 0.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 0.125rem solid #e0e0e0;
border-top-color: #8159b8;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
.loading-subtitle { .loading-subtitle {

View file

@ -13,24 +13,27 @@
<body> <body>
<div class="card"> <div class="card">
<div id="health-status" <div id="health-status"
hx-get="/health/fragment" hx-get="/fragment/health"
hx-trigger="load, every 10s" hx-trigger="load, every 10s"
hx-indicator="#health-status" hx-indicator="#health-status">
class="loading"> <div class="loading">
Loading health status... <div class="spinner"></div>
</div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Signal</h2> <h2>Signal</h2>
<div id="signal-info" <div id="signal-info"
hx-get="/signal-info/fragment" hx-get="/fragment/signal-info"
hx-trigger="load, accountLinked from:body" hx-trigger="load, accountLinked from:body"
hx-indicator="#signal-info"> hx-indicator="#signal-info">
<div class="loading">Loading Signal status...</div> <div class="loading">
<div class="spinner"></div>
</div>
</div> </div>
<div id="qr-section" <div id="qr-section"
hx-get="/signal-info/fragment" hx-get="/fragment/signal-info"
hx-trigger="accountLinked from:body" hx-trigger="accountLinked from:body"
hx-swap="none"></div> hx-swap="none"></div>
</div> </div>
@ -38,11 +41,12 @@
<div class="card"> <div class="card">
<h2>Endpoints</h2> <h2>Endpoints</h2>
<div id="endpoints-list" <div id="endpoints-list"
hx-get="/endpoints/fragment" hx-get="/fragment/endpoints"
hx-trigger="load" hx-trigger="load"
hx-indicator="#endpoints-list" hx-indicator="#endpoints-list">
class="loading"> <div class="loading">
Loading endpoints... <div class="spinner"></div>
</div>
</div> </div>
</div> </div>
</body> </body>

View file

@ -6,11 +6,13 @@ import {
checkSignalCli, checkSignalCli,
finishLink, finishLink,
generateLinkQR, generateLinkQR,
getAccount,
hasValidAccount, hasValidAccount,
initSignal, initSignal,
} from '@/modules/signal'; } from '@/modules/signal';
import { getAllMappings, remove } from '@/modules/store'; import { getAllMappings, remove } from '@/modules/store';
import { verifyApiKey } from '@/utils/auth'; import { verifyApiKey } from '@/utils/auth';
import { formatPhoneNumber, formatUptime } from '@/utils/format';
let cachedQR: string | null = null; let cachedQR: string | null = null;
let qrCacheTime = 0; let qrCacheTime = 0;
@ -22,19 +24,22 @@ export const handleHealthFragment = async () => {
const linked = signalOk && (await hasValidAccount()); const linked = signalOk && (await hasValidAccount());
const imap = isImapConnected(); const imap = isImapConnected();
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD; const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
const accountNumber = getAccount();
const html = ` const html = `
<div class="status"> <div class="status">
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}"> <div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
Signal Daemon: ${signalOk ? 'Running' : 'Stopped'} Signal Network: ${signalOk ? 'Connected' : 'Disconnected'}
</div> </div>
<div class="status-item ${linked ? 'status-ok' : 'status-error'}"> <div class="status-item ${linked ? 'status-ok' : 'status-error'}">
Account: ${linked ? 'Linked' : 'Unlinked'} Account: ${linked ? 'Linked' : 'Unlinked'}
${linked && accountNumber ? `<span class="tooltip">${formatPhoneNumber(accountNumber)}</span>` : ''}
</div> </div>
${ ${
hasProtonConfig hasProtonConfig
? `<div class="status-item ${imap ? 'status-ok' : 'status-error'}"> ? `<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
Proton Mail: ${imap ? 'Connected' : 'Disconnected'} Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
${imap ? `<span class="tooltip">${PROTON_IMAP_USERNAME}</span>` : ''}
</div>` </div>`
: '' : ''
} }
@ -83,7 +88,7 @@ export const handleEndpointsFragment = async () => {
</div> </div>
<button <button
class="btn-delete" class="btn-delete"
hx-delete="/endpoint/delete/${encodeURIComponent(e.endpoint)}" hx-delete="/action/delete-endpoint/${encodeURIComponent(e.endpoint)}"
hx-target="#endpoints-list" hx-target="#endpoints-list"
hx-swap="innerHTML" hx-swap="innerHTML"
>Delete</button> >Delete</button>
@ -157,7 +162,7 @@ admin.get('/api/health', async (c) => {
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD; const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
uptime: process.uptime(), uptime: formatUptime(process.uptime()),
signal: { signal: {
daemon: signalOk ? 'running' : 'stopped', daemon: signalOk ? 'running' : 'stopped',
linked, linked,
@ -171,18 +176,18 @@ admin.get('/api/health', async (c) => {
return c.json(result); return c.json(result);
}); });
admin.get('/health/fragment', async (c) => { admin.get('/fragment/health', async (c) => {
const { html } = await handleHealthFragment(); const { html } = await handleHealthFragment();
return c.html(html); return c.html(html);
}); });
admin.get('/signal-info/fragment', async (c) => c.html(await handleSignalInfoFragment())); admin.get('/fragment/signal-info', async (c) => c.html(await handleSignalInfoFragment()));
admin.get('/endpoints/fragment', async (c) => c.html(await handleEndpointsFragment())); admin.get('/fragment/endpoints', async (c) => c.html(await handleEndpointsFragment()));
admin.get('/link/qr-section', async (c) => c.html(await handleQRSection())); admin.get('/fragment/link-qr', async (c) => c.html(await handleQRSection()));
admin.delete('/endpoint/delete/:endpoint', async (c) => { admin.delete('/action/delete-endpoint/:endpoint', async (c) => {
const endpoint = decodeURIComponent(c.req.param('endpoint')); const endpoint = decodeURIComponent(c.req.param('endpoint'));
if (!endpoint) { if (!endpoint) {

35
server/utils/format.ts Normal file
View file

@ -0,0 +1,35 @@
export const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
};
export const formatPhoneNumber = (phone: string): string => {
if (!phone) return phone;
// US/Canada: +1 (234) 567-8901
if (phone.startsWith('+1') && phone.length === 12) {
return `+1 (${phone.slice(2, 5)}) ${phone.slice(5, 8)}-${phone.slice(8)}`;
}
// International: +XX XXXX XXXX
if (phone.startsWith('+')) {
const match = phone.match(/^\+(\d{1,3})(\d{3,4})(\d+)$/);
if (match) {
const [, code, first, rest] = match;
const parts = rest?.match(/.{1,4}/g) || [];
return `+${code} ${first} ${parts.join(' ')}`;
}
}
return phone;
};