mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
improving README, small refactoring updates
This commit is contained in:
parent
a1fe052a8e
commit
4b801a6353
12 changed files with 199 additions and 45 deletions
61
README.md
61
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
|
||||
|
||||
<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
|
||||
|
||||
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 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
|
||||
|
|
|
|||
BIN
assets/screenshots/1.webp
Normal file
BIN
assets/screenshots/1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/screenshots/2.webp
Normal file
BIN
assets/screenshots/2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/screenshots/3.webp
Normal file
BIN
assets/screenshots/3.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/screenshots/4.webp
Normal file
BIN
assets/screenshots/4.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*<?/);
|
||||
const from = nameMatch?.[1]?.trim() || rawFrom;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from '@/constants/config';
|
||||
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
|
||||
import { formatPhoneNumber } from '@/utils/format';
|
||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||
import { call } from '@/utils/rpc';
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ export async function initSignal({ accountOverride }: { accountOverride?: string
|
|||
const [firstAccount] = result;
|
||||
if (firstAccount) {
|
||||
account = firstAccount.number;
|
||||
logVerbose(`Signal account initialized: ${account}`);
|
||||
logVerbose(`Signal account initialized: ${formatPhoneNumber(account)}`);
|
||||
logSuccess('Signal account linked');
|
||||
return { linked: true, account };
|
||||
}
|
||||
|
|
@ -267,3 +268,5 @@ export async function startDaemon() {
|
|||
}
|
||||
|
||||
export const cleanupDaemon = () => daemon?.kill();
|
||||
|
||||
export const getAccount = () => account;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -13,24 +13,27 @@
|
|||
<body>
|
||||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/health/fragment"
|
||||
hx-get="/fragment/health"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#health-status"
|
||||
class="loading">
|
||||
Loading health status...
|
||||
hx-indicator="#health-status">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Signal</h2>
|
||||
<div id="signal-info"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-get="/fragment/signal-info"
|
||||
hx-trigger="load, accountLinked from:body"
|
||||
hx-indicator="#signal-info">
|
||||
<div class="loading">Loading Signal status...</div>
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr-section"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-get="/fragment/signal-info"
|
||||
hx-trigger="accountLinked from:body"
|
||||
hx-swap="none"></div>
|
||||
</div>
|
||||
|
|
@ -38,11 +41,12 @@
|
|||
<div class="card">
|
||||
<h2>Endpoints</h2>
|
||||
<div id="endpoints-list"
|
||||
hx-get="/endpoints/fragment"
|
||||
hx-get="/fragment/endpoints"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#endpoints-list"
|
||||
class="loading">
|
||||
Loading endpoints...
|
||||
hx-indicator="#endpoints-list">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="status">
|
||||
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
|
||||
Signal Daemon: ${signalOk ? 'Running' : 'Stopped'}
|
||||
Signal Network: ${signalOk ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<div class="status-item ${linked ? 'status-ok' : 'status-error'}">
|
||||
Account: ${linked ? 'Linked' : 'Unlinked'}
|
||||
${linked && accountNumber ? `<span class="tooltip">${formatPhoneNumber(accountNumber)}</span>` : ''}
|
||||
</div>
|
||||
${
|
||||
hasProtonConfig
|
||||
? `<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||
Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
|
||||
${imap ? `<span class="tooltip">${PROTON_IMAP_USERNAME}</span>` : ''}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
|
@ -83,7 +88,7 @@ export const handleEndpointsFragment = async () => {
|
|||
</div>
|
||||
<button
|
||||
class="btn-delete"
|
||||
hx-delete="/endpoint/delete/${encodeURIComponent(e.endpoint)}"
|
||||
hx-delete="/action/delete-endpoint/${encodeURIComponent(e.endpoint)}"
|
||||
hx-target="#endpoints-list"
|
||||
hx-swap="innerHTML"
|
||||
>Delete</button>
|
||||
|
|
@ -157,7 +162,7 @@ admin.get('/api/health', async (c) => {
|
|||
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
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) {
|
||||
|
|
|
|||
35
server/utils/format.ts
Normal file
35
server/utils/format.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue