diff --git a/README.md b/README.md index 5e1f101..00fd342 100644 --- a/README.md +++ b/README.md @@ -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. -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? @@ -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. +## 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 ### 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 ``` -2. **Login to Proton Mail Bridge**: +2.**Login to Proton Mail Bridge**: - At the `>>>` prompt, run: `login` - Enter your email - Enter your password - Enter your 2FA code -3. **Get IMAP credentials**: +3.**Get IMAP credentials**: - Run: `info` - Copy the Username and Password shown - Run: `exit` to quit -4. **Add credentials to .env**: +4.**Add credentials to .env**: ```bash # 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 ``` -5. **Start all services with Proton Mail**: +5.**Start all services with Proton Mail**: ```bash 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. -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 @@ -102,10 +108,31 @@ nano .env # Start SUP server 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 + + + +#### Scan the QR code from your Signal app by linking SUP as a device + + + +#### A healthy setup with a linked account + + + +#### A healthy setup with a linked account and the optional Proton Mail integration + + + + + ### Development For local development, install Bun and signal-cli: @@ -118,6 +145,8 @@ git clone https://github.com/lone-cloud/sup.git cd sup bun install +cd server +bun start ``` 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 ``` -Or run services directly with Bun: - -```bash -bun install -bun --filter sup-server dev -``` - ## Real-World Examples ### 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 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 - **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server diff --git a/assets/screenshots/1.webp b/assets/screenshots/1.webp new file mode 100644 index 0000000..18287eb Binary files /dev/null and b/assets/screenshots/1.webp differ diff --git a/assets/screenshots/2.webp b/assets/screenshots/2.webp new file mode 100644 index 0000000..04ad05e Binary files /dev/null and b/assets/screenshots/2.webp differ diff --git a/assets/screenshots/3.webp b/assets/screenshots/3.webp new file mode 100644 index 0000000..ae5f67a Binary files /dev/null and b/assets/screenshots/3.webp differ diff --git a/assets/screenshots/4.webp b/assets/screenshots/4.webp new file mode 100644 index 0000000..cf3474c Binary files /dev/null and b/assets/screenshots/4.webp differ diff --git a/server/.env.example b/server/.env.example index 15481e7..9cba50b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,11 +1,10 @@ +# Required: API key for authentication +# API_KEY=your-secret-key-here + # Optional: Server port # Default: 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 # Default: false # ALLOW_INSECURE_HTTP=true @@ -16,7 +15,7 @@ # Optional: Device name shown in Signal app's linked devices list # Default: SUP -# DEVICE_NAME=SUP dev +# DEVICE_NAME=What SUP # Optional: Enable verbose logging # Default: false diff --git a/server/modules/protonmail.ts b/server/modules/protonmail.ts index d5b8939..0104494 100644 --- a/server/modules/protonmail.ts +++ b/server/modules/protonmail.ts @@ -11,6 +11,7 @@ import { getOrCreateGroup } from '@/modules/store'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; let imapConnected = false; +let monitorStartTime = 0; export const isImapConnected = () => imapConnected; @@ -68,11 +69,18 @@ export async function startProtonMonitor() { return; } - imap.on('mail', async (numNewMsgs: number) => { - logVerbose(`${numNewMsgs} new message(s) received`); + if (!monitorStartTime) { + 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}:*`, { - bodies: 'HEADER.FIELDS (FROM SUBJECT)', + imap.on('mail', async (numNewMsgs: number) => { + 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, }); @@ -86,6 +94,19 @@ export async function startProtonMonitor() { const header = Imap.parseHeader(buffer); const rawFrom = header.from?.[0] || 'Unknown sender'; 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* daemon?.kill(); + +export const getAccount = () => account; diff --git a/server/public/index.css b/server/public/index.css index f94220a..24a6c61 100644 --- a/server/public/index.css +++ b/server/public/index.css @@ -72,6 +72,46 @@ h2 { padding: 0.625rem 1rem; border-radius: 0.25rem; 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 { @@ -186,6 +226,24 @@ h2 { .loading { 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 { diff --git a/server/public/index.html b/server/public/index.html index 1436185..fa37cb4 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -13,24 +13,27 @@
- Loading health status... + hx-indicator="#health-status"> +
+
+

Signal

-
Loading Signal status...
+
+
+
@@ -38,11 +41,12 @@

Endpoints

- Loading endpoints... + hx-indicator="#endpoints-list"> +
+
+
diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 000b016..66e5f6d 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -6,11 +6,13 @@ import { checkSignalCli, finishLink, generateLinkQR, + getAccount, hasValidAccount, initSignal, } from '@/modules/signal'; import { getAllMappings, remove } from '@/modules/store'; import { verifyApiKey } from '@/utils/auth'; +import { formatPhoneNumber, formatUptime } from '@/utils/format'; let cachedQR: string | null = null; let qrCacheTime = 0; @@ -22,19 +24,22 @@ export const handleHealthFragment = async () => { const linked = signalOk && (await hasValidAccount()); const imap = isImapConnected(); const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD; + const accountNumber = getAccount(); const html = `
- Signal Daemon: ${signalOk ? 'Running' : 'Stopped'} + Signal Network: ${signalOk ? 'Connected' : 'Disconnected'}
Account: ${linked ? 'Linked' : 'Unlinked'} + ${linked && accountNumber ? `${formatPhoneNumber(accountNumber)}` : ''}
${ hasProtonConfig ? `
Proton Mail: ${imap ? 'Connected' : 'Disconnected'} + ${imap ? `${PROTON_IMAP_USERNAME}` : ''}
` : '' } @@ -83,7 +88,7 @@ export const handleEndpointsFragment = async () => {
@@ -157,7 +162,7 @@ admin.get('/api/health', async (c) => { const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD; const result: Record = { - uptime: process.uptime(), + uptime: formatUptime(process.uptime()), signal: { daemon: signalOk ? 'running' : 'stopped', linked, @@ -171,18 +176,18 @@ admin.get('/api/health', async (c) => { return c.json(result); }); -admin.get('/health/fragment', async (c) => { +admin.get('/fragment/health', async (c) => { const { html } = await handleHealthFragment(); 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')); if (!endpoint) { diff --git a/server/utils/format.ts b/server/utils/format.ts new file mode 100644 index 0000000..b0d4407 --- /dev/null +++ b/server/utils/format.ts @@ -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; +};