Build & Release / Docker / backend (push) Has been cancelled
Build & Release / Docker / frontend (push) Has been cancelled
Build & Release / Desktop / Linux & Windows (push) Has been cancelled
Build & Release / Deploy to server (push) Has been cancelled
Build & Release / Create release (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
9.8 KiB
Markdown
343 lines
9.8 KiB
Markdown
# CloudLink
|
|
|
|
End-to-end encrypted messaging and cloud drive. The server stores only ciphertext — it cannot read messages or files under any circumstance.
|
|
|
|
## Features
|
|
|
|
- **Zero-knowledge messaging** — X25519 ECDH key exchange; AES-256-GCM encrypted messages
|
|
- **Encrypted drive** — Files are encrypted in-browser before upload; server stores opaque blobs
|
|
- **Real-time delivery** — WebSocket relay; server forwards ciphertext without decrypting
|
|
- **Two-factor authentication** — TOTP (Google Authenticator, Authy, etc.) with backup codes
|
|
- **Progressive Web App** — installable from the browser on any OS
|
|
- **Desktop app** — native Electron wrapper for Mac, Windows, and Linux
|
|
- **Admin portal** — manage users, storage quotas, and admin flags
|
|
- **CI/CD** — Gitea Actions builds Docker images and desktop installers on every push
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser / Desktop app
|
|
│ HTTPS + WSS
|
|
▼
|
|
nginx (TLS termination, reverse proxy)
|
|
│
|
|
┌────┴──────┐
|
|
│ frontend │ React 18 SPA served by nginx:alpine
|
|
└───────────┘
|
|
│ /api/* and /ws proxied
|
|
▼
|
|
┌────────────┐
|
|
│ backend │ Express + TypeScript, port 3001
|
|
└─────┬──────┘
|
|
│
|
|
┌─────▼──────┐
|
|
│ PostgreSQL │ pg:16-alpine, persistent volume
|
|
└────────────┘
|
|
```
|
|
|
|
All encryption and decryption happens exclusively in the browser/client. The backend is a dumb relay and storage layer.
|
|
|
|
---
|
|
|
|
## Encryption Model
|
|
|
|
### Keys
|
|
|
|
| Key | Algorithm | Where it lives |
|
|
|---|---|---|
|
|
| Password-derived key | PBKDF2-SHA256 (600 000 iterations) | In-memory only, derived at login |
|
|
| Master key | AES-256-GCM | Encrypted with derived key, stored on server |
|
|
| X25519 keypair | X25519 ECDH | Private key encrypted with master key, stored on server; public key stored plaintext |
|
|
| Conversation key | AES-256-GCM | Per-conversation symmetric key, encrypted with each member's X25519 public key |
|
|
| File key | AES-256-GCM | Per-file symmetric key, encrypted with owner's master key |
|
|
|
|
### Message flow
|
|
|
|
1. Alice opens a conversation with Bob
|
|
2. Client generates a random AES-256-GCM conversation key
|
|
3. Key is sealed with Bob's X25519 public key (ephemeral ECDH + HKDF + AES-GCM)
|
|
4. Ciphertext of the key is stored in `conversation_members.encrypted_key`
|
|
5. Each message is encrypted with the conversation key
|
|
6. Server stores and relays only ciphertext — it never sees plaintext
|
|
|
|
### File flow
|
|
|
|
1. Client generates a random AES-256-GCM file key
|
|
2. File is encrypted in-browser with the file key
|
|
3. File key is encrypted with the user's master key
|
|
4. Encrypted file and encrypted file key are uploaded; filename is also encrypted
|
|
5. Server stores opaque bytes at a UUID-based path
|
|
|
|
---
|
|
|
|
## Tech Stack
|
|
|
|
### Backend
|
|
- Node.js 20 + TypeScript
|
|
- Express 4, Helmet, express-rate-limit
|
|
- `ws` WebSocket server
|
|
- PostgreSQL 16 with pgcrypto
|
|
- Argon2id for password hashing
|
|
- `jsonwebtoken` — 15-min access tokens + 30-day refresh tokens
|
|
- `otplib` for TOTP 2FA
|
|
- `multer` for file uploads
|
|
- `zod` for request validation
|
|
|
|
### Frontend
|
|
- React 18 + Vite + TypeScript
|
|
- Tailwind CSS
|
|
- Zustand (auth + crypto keys in-memory store)
|
|
- TanStack Query v5
|
|
- React Router v6
|
|
- `vite-plugin-pwa` (PWA / installable)
|
|
- Web Crypto API (no external crypto libraries)
|
|
- `lucide-react` icons
|
|
- `date-fns`
|
|
|
|
### Desktop
|
|
- Electron 33
|
|
- electron-store (persistent server URL)
|
|
- electron-builder (Mac DMG, Windows NSIS, Linux AppImage)
|
|
|
|
### Infrastructure
|
|
- Docker + Docker Compose
|
|
- nginx (TLS, reverse proxy, HSTS, security headers)
|
|
- Gitea Actions (CI/CD)
|
|
|
|
---
|
|
|
|
## Running Locally (Development)
|
|
|
|
### Prerequisites
|
|
- Node.js 20+
|
|
- Docker + Docker Compose
|
|
|
|
### 1. Clone and configure
|
|
|
|
```bash
|
|
git clone https://git.necloud.us/nate/cloudlink.git
|
|
cd cloudlink
|
|
cp .env.example .env # edit values
|
|
```
|
|
|
|
`.env` minimum:
|
|
|
|
```env
|
|
POSTGRES_DB=cloudlink
|
|
POSTGRES_USER=cloudlink
|
|
POSTGRES_PASSWORD=changeme
|
|
JWT_SECRET=change-me-at-least-32-chars
|
|
JWT_REFRESH_SECRET=change-me-at-least-32-chars
|
|
MAX_FILE_SIZE_MB=100
|
|
```
|
|
|
|
### 2. Start the database
|
|
|
|
```bash
|
|
docker compose up -d postgres
|
|
```
|
|
|
|
### 3. Run the backend
|
|
|
|
```bash
|
|
cd backend
|
|
npm install
|
|
npm run dev # http://localhost:3001
|
|
```
|
|
|
|
### 4. Run the frontend
|
|
|
|
```bash
|
|
cd frontend
|
|
npm install
|
|
npm run dev # http://localhost:5173
|
|
```
|
|
|
|
Set `VITE_API_URL=http://localhost:3001/api` and `VITE_WS_URL=ws://localhost:3001/ws` in `frontend/.env.local`.
|
|
|
|
---
|
|
|
|
## Production Deployment (Docker)
|
|
|
|
### Prerequisites
|
|
- Docker + Docker Compose on the server
|
|
- SSL certificate at `nginx/ssl/fullchain.pem` and `nginx/ssl/privkey.pem`
|
|
|
|
### Option A — Build locally, run on server
|
|
|
|
```bash
|
|
docker compose build
|
|
docker compose up -d
|
|
```
|
|
|
|
### Option B — Pull pre-built images from CI (recommended)
|
|
|
|
After a successful Gitea Actions run:
|
|
|
|
```bash
|
|
echo "$REGISTRY_TOKEN" | docker login git.necloud.us -u nate --password-stdin
|
|
docker compose pull backend frontend
|
|
docker compose up -d --no-build
|
|
```
|
|
|
|
### Environment variables (production `.env`)
|
|
|
|
```env
|
|
POSTGRES_DB=cloudlink
|
|
POSTGRES_USER=cloudlink
|
|
POSTGRES_PASSWORD=<strong-password>
|
|
JWT_SECRET=<64-char-random-string>
|
|
JWT_REFRESH_SECRET=<64-char-random-string>
|
|
HOST_PORT=443
|
|
MAX_FILE_SIZE_MB=100
|
|
```
|
|
|
|
Generate strong secrets:
|
|
```bash
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
### First admin user
|
|
|
|
After the stack is up, register a normal account, then promote it:
|
|
|
|
```bash
|
|
docker exec -i cloudlink-postgres-1 psql -U cloudlink -d cloudlink \
|
|
-c "UPDATE users SET is_admin = true WHERE email = 'you@example.com';"
|
|
```
|
|
|
|
Subsequent admins can be toggled from the Admin portal in the UI.
|
|
|
|
---
|
|
|
|
## Desktop App
|
|
|
|
### Install pre-built (from Gitea release)
|
|
|
|
Download the installer for your OS from [Releases](https://git.necloud.us/nate/cloudlink/releases):
|
|
|
|
| OS | File | Install |
|
|
|---|---|---|
|
|
| Windows | `CloudLink Setup.exe` | Run the installer, follow wizard |
|
|
| Linux | `CloudLink.AppImage` | `chmod +x CloudLink.AppImage && ./CloudLink.AppImage` |
|
|
| Mac | `CloudLink.dmg` | Open DMG, drag to Applications *(built locally — see below)* |
|
|
|
|
On first launch the app prompts for your CloudLink server URL (e.g. `https://cloudlink.example.com`). This is saved and used for all future launches.
|
|
|
|
### Build locally
|
|
|
|
```bash
|
|
cd desktop
|
|
npm install
|
|
|
|
npm run build:mac # requires macOS
|
|
npm run build:win # requires macOS or Linux (Wine)
|
|
npm run build:linux # any platform
|
|
```
|
|
|
|
Outputs go to `desktop/dist/`.
|
|
|
|
---
|
|
|
|
## Admin Portal
|
|
|
|
Accessible at `/admin` for users with `is_admin = true`.
|
|
|
|
- View all users, registration date, last seen
|
|
- Per-user storage usage vs quota (colour-coded bar)
|
|
- Inline quota editing (MB)
|
|
- Toggle admin flag per user
|
|
- Aggregate stats (total users, total storage, total files)
|
|
|
|
---
|
|
|
|
## CI/CD (Gitea Actions)
|
|
|
|
Workflow: `.gitea/workflows/docker-build.yml`
|
|
|
|
| Job | Trigger | Action |
|
|
|---|---|---|
|
|
| `build-backend` | every push / PR | Build + push `backend:latest` and `backend:<sha>` to Gitea registry |
|
|
| `build-frontend` | every push / PR | Build + push `frontend:latest` and `frontend:<sha>` |
|
|
| `build-desktop` | every push / PR | Build Linux AppImage + Windows NSIS; upload as workflow artifacts (30-day retention) |
|
|
| `deploy` | push to `master` | SSH into server, pull new images, restart containers |
|
|
| `release` | push of a `v*` tag | Create Gitea release, attach desktop installers |
|
|
|
|
### Required secrets (Repo → Settings → Secrets)
|
|
|
|
| Secret | Value |
|
|
|---|---|
|
|
| `REGISTRY_USER` | Gitea username (e.g. `nate`) |
|
|
| `REGISTRY_TOKEN` | Gitea API token with `package:write` scope |
|
|
| `DEPLOY_HOST` | Server hostname or IP |
|
|
| `DEPLOY_USER` | SSH user |
|
|
| `DEPLOY_SSH_KEY` | SSH private key (contents of `~/.ssh/id_ed25519`) |
|
|
| `DEPLOY_PATH` | Absolute path to cloudlink on server (e.g. `/opt/cloudlink`) |
|
|
|
|
### Tagging a release
|
|
|
|
```bash
|
|
git tag v1.2.0 && git push origin v1.2.0
|
|
```
|
|
|
|
This triggers the `release` job which creates a Gitea release entry and attaches the built installers.
|
|
|
|
### Runner setup
|
|
|
|
A Gitea act runner is required. Install on your server:
|
|
|
|
```bash
|
|
wget https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64 -O act_runner
|
|
chmod +x act_runner
|
|
|
|
# Get registration token from:
|
|
# Gitea → Site Administration → Actions → Runners → Create Runner
|
|
|
|
./act_runner register \
|
|
--no-interactive \
|
|
--instance https://git.necloud.us \
|
|
--token <TOKEN> \
|
|
--name cloudlink-runner \
|
|
--labels ubuntu-latest:docker://node:20-bullseye
|
|
|
|
./act_runner daemon
|
|
```
|
|
|
|
The runner host must have Docker installed.
|
|
|
|
---
|
|
|
|
## Security Notes
|
|
|
|
- Crypto keys are **never** written to disk or localStorage — they live in Zustand's in-memory store and are lost on page reload (by design). Users re-derive them from their password at each login.
|
|
- The server stores only: `password_hash` (Argon2id), `kdf_salt`, `encrypted_master_key`, `encrypted_private_key`, and ciphertext blobs. No plaintext content.
|
|
- JWTs are short-lived (15 min). Refresh tokens are rotated on use and hashed before storage.
|
|
- nginx enforces HSTS, X-Frame-Options, X-Content-Type-Options, and a strict CSP.
|
|
- File paths on disk are UUID-based — not guessable from filenames.
|
|
- Rate limiting is applied to auth endpoints.
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
See [`backend/src/db/schema.sql`](backend/src/db/schema.sql). Tables:
|
|
|
|
| Table | Purpose |
|
|
|---|---|
|
|
| `users` | Account credentials, KDF salt, encrypted master key, 2FA state, quota |
|
|
| `user_keys` | X25519 public key + encrypted private key |
|
|
| `refresh_tokens` | Hashed refresh tokens with expiry |
|
|
| `conversations` | Direct message threads |
|
|
| `conversation_members` | Per-member encrypted conversation key |
|
|
| `messages` | Ciphertext-only message records |
|
|
| `files` | Encrypted file metadata + storage path |
|
|
|
|
---
|
|
|
|
## Repository
|
|
|
|
Source: `https://git.necloud.us/nate/cloudlink`
|
|
Container registry: `git.necloud.us/nate/cloudlink/{backend,frontend}:latest`
|