docs: add public documentation for contributors and users

Created 12 public docs derived from internal vault:
- Setup guides: quick-start, server-configuration, livekit-setup, deployment
- Networking: port-forwarding, tailscale
- References: api, protocol, schema, client-architecture
- Community: contributing, security

Updated .gitignore to only exclude docs/brain/ (internal vault),
allowing docs/ to be tracked. Updated README with expanded quick
start, voice/video setup, networking ports, and doc links.
This commit is contained in:
jevb
2026-03-30 22:31:06 +02:00
parent 65dc00cad0
commit ec5775910f
14 changed files with 3202 additions and 20 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ CLAUDE.md
.github/instructions/
# AI-specific / internal planning docs
docs/
docs/brain/
skills/
# Server runtime artifacts
+71 -19
View File
@@ -107,13 +107,46 @@ dependencies, works fully on LAN.
## Quick Start
1. Download the latest release from
1. Download `chatserver.exe` and the OwnCord installer from
[GitHub Releases](https://github.com/J3vb/OwnCord/releases)
2. Run `chatserver.exe` — generates `config.yaml` on first run
3. Open `https://localhost:8443/admin` to access the admin panel
4. Generate an invite code and share it with friends
5. Friends download the client installer and connect
using your server address
2. Run `chatserver.exe` — generates `config.yaml` and a `data/`
directory (database, TLS certs, uploads, backups) on first run
3. Open `https://localhost:8443/admin` to create the Owner account
4. Generate an invite code in the admin panel and share it
5. Friends install the client, enter your server address
(`ip:8443`), and register with the invite code
The client uses TOFU (Trust On First Use) for self-signed
certificates — it prompts to trust the server on first
connection, then pins it for future sessions.
### Voice & Video Setup (Optional)
Voice and video require [LiveKit Server](https://github.com/livekit/livekit/releases):
1. Download `livekit-server` from the LiveKit releases page
2. Edit `config.yaml` and set:
```yaml
voice:
livekit_api_key: "devkey" # any string
livekit_api_secret: "secret-min-32-characters-long!!" # min 32 chars
livekit_binary: "C:/path/to/livekit-server.exe"
```
3. Restart `chatserver.exe` — it auto-starts LiveKit as a
companion process
### Networking
For friends outside your LAN, you need to forward these ports:
| Port | Protocol | Purpose |
| ---- | -------- | ------- |
| `8443` | TCP | HTTPS, WebSocket, REST API |
| `7881` | TCP | LiveKit signaling (voice/video) |
| `50000-60000` | UDP | LiveKit WebRTC media (voice/video) |
Alternatively, use Tailscale for zero-config networking
with no port forwarding.
## Architecture
@@ -223,7 +256,18 @@ npm run lint:fix # ESLint auto-fix
## Configuration
The server generates a `config.yaml` on first run. Key settings:
The server generates a `config.yaml` on first run. All runtime data
is stored in a `data/` directory alongside the executable:
```text
data/
├── owncord.db # SQLite database
├── certs/ # TLS certificates (auto-generated if self_signed)
├── uploads/ # User-uploaded files
└── backups/ # Database backups
```
Key settings:
| Setting | Default | Description |
| ------- | ------- | ----------- |
@@ -252,19 +296,27 @@ To enable signed releases in CI, add these GitHub repository secrets:
## Documentation
Detailed docs live in the `docs/brain/` Obsidian vault:
- [Quick Start Guide](docs/quick-start.md)
- [Server Configuration](docs/server-configuration.md)
- [LiveKit Setup (Voice/Video)](docs/livekit-setup.md)
- [Deployment Guide](docs/deployment.md)
- [Port Forwarding](docs/port-forwarding.md)
- [Tailscale Guide](docs/tailscale.md)
- [REST API Reference](docs/api.md)
- [WebSocket Protocol](docs/protocol.md)
- [Database Schema](docs/schema.md)
- [Client Architecture](docs/client-architecture.md)
- [Contributing](docs/contributing.md)
- [Security Policy](docs/security.md)
- [Quick Start Guide](docs/brain/08-Guides/quick-start.md)
- [Port Forwarding Guide](docs/brain/08-Guides/port-forwarding.md)
- [Tailscale Guide](docs/brain/08-Guides/tailscale.md)
- [Client Architecture](docs/brain/06-Specs/CLIENT-ARCHITECTURE.md)
- [Server Spec](docs/brain/06-Specs/CHATSERVER.md)
- [WebSocket Protocol](docs/brain/06-Specs/PROTOCOL.md)
- [REST API](docs/brain/06-Specs/API.md)
- [Database Schema](docs/brain/06-Specs/SCHEMA.md)
- [Testing Strategy](docs/brain/06-Specs/TESTING-STRATEGY.md)
- [Contributing](docs/brain/08-Guides/CONTRIBUTING.md)
- [Security](docs/brain/08-Guides/SECURITY.md)
## Contributing
1. Fork the repo and create a feature branch from `dev`
2. Follow existing code style and conventions
3. Write tests for new functionality
4. Open a PR against `dev` with a clear description
See [Contributing Guide](docs/contributing.md) for details.
## Tech Stack
+849
View File
@@ -0,0 +1,849 @@
# REST API Reference
OwnCord server REST API reference. All endpoints use the base URL `https://{server}:{port}/api/v1`.
---
## Authentication
All authenticated endpoints require a session token delivered via the `Authorization: Bearer {token}` header. Tokens are obtained from `POST /api/v1/auth/login`, `POST /api/v1/auth/register`, or `POST /api/v1/auth/verify-totp` after a partial 2FA challenge.
### Session Lifecycle
- Sessions are created on login/register and stored with a SHA-256 hash of the raw token, the client IP, User-Agent, and an expiry timestamp.
- Each authenticated request updates the session's `last_active` timestamp.
- Banned users are rejected at the middleware level with `403 FORBIDDEN`.
### Middleware Stack (all routes)
1. **RequestID** -- assigns a unique `X-Request-Id` response header.
2. **Recoverer** -- catches panics and returns 500.
3. **Request Logger** -- structured logging of method, path, status, duration.
4. **SecurityHeaders** -- sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy: default-src 'self'`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`.
5. **MaxBodySize** -- 1 MiB default for all routes except `/api/v1/uploads` (which has its own 100 MiB limit).
---
## Standard Error Response
All error responses use this JSON envelope:
```json
{
"error": "ERROR_CODE",
"message": "Human-readable detail"
}
```
### Error Codes
| Code | HTTP Status | When It Occurs |
| ---- | ----------- | -------------- |
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params |
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
| `TOO_LARGE` | 413 | File exceeds upload size limit |
| `SERVER_ERROR` / `INTERNAL` | 500 | Internal server error |
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, asset download) |
---
## Auth Endpoints
### POST /api/v1/auth/register
Create a new account using an invite code. The first user is created via `/admin/api/setup` instead.
**Auth:** None (public)
**Rate limit:** 3 requests/minute per IP
#### Request
```json
{
"username": "alex",
"password": "MyStr0ng!Pass",
"invite_code": "abc123def"
}
```
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
| `password` | string | Yes | Validated for strength (min length, complexity). |
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
#### Response 201 Created
```json
{
"token": "raw-session-token-64-chars",
"user": {
"id": 2,
"username": "alex",
"avatar": "",
"status": "offline",
"role_id": 4,
"totp_enabled": false,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
| 500 | `SERVER_ERROR` | Hashing failure, session creation failure, or DB error |
---
### POST /api/v1/auth/login
Authenticate with username and password.
**Auth:** None (public)
**Rate limit:** 60 requests/minute per IP. After 10 consecutive failures from the same IP, the IP is locked out for 15 minutes.
#### Request
```json
{
"username": "alex",
"password": "MyStr0ng!Pass"
}
```
#### Response 200 OK
If the account does not have TOTP enabled:
```json
{
"token": "raw-session-token-64-chars",
"requires_2fa": false,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "offline",
"role_id": 4,
"totp_enabled": false,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
If the account has TOTP enabled:
```json
{
"partial_token": "opaque-partial-token",
"requires_2fa": true
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Missing username or password |
| 401 | `UNAUTHORIZED` | Wrong username or password |
| 403 | `FORBIDDEN` | Account is banned/suspended |
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### POST /api/v1/auth/verify-totp
Complete a TOTP login challenge started by `POST /api/v1/auth/login`.
**Auth:** Required with the `partial_token` from the login response
**Rate limit:** 10 requests/minute per IP, plus a 5-attempt budget per partial challenge
#### Request
```json
{
"code": "123456"
}
```
#### Response 200 OK
```json
{
"token": "raw-session-token-64-chars",
"requires_2fa": false,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "offline",
"role_id": 4,
"totp_enabled": true,
"created_at": "2026-03-24T12:00:00Z"
}
}
```
#### Errors
| Status | Code | Cause |
| ------ | ---- | ----- |
| 400 | `INVALID_INPUT` | Malformed request body |
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
| 500 | `SERVER_ERROR` | Session creation failure |
---
### GET /api/v1/auth/me
Get the current authenticated user's profile.
**Auth:** Required (Bearer token)
#### Response 200 OK
```json
{
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"status": "online",
"role_id": 2,
"totp_enabled": true,
"created_at": "2026-03-24T12:00:00Z"
}
```
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | User ID |
| `username` | string | Display name |
| `avatar` | string | Avatar filename (UUID) or empty string |
| `status` | string | One of: `online`, `idle`, `dnd`, `offline` |
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
| `created_at` | string | ISO 8601 timestamp |
---
### POST /api/v1/auth/logout
Invalidate the current session token.
**Auth:** Required (Bearer token)
#### Response 204 No Content
---
### POST /api/v1/users/me/totp/enable
Start TOTP enrollment for the authenticated user. The secret is not persisted until `/api/v1/users/me/totp/confirm` succeeds.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass"
}
```
#### Response 200 OK
```json
{
"qr_uri": "otpauth://totp/OwnCord:alex?...",
"backup_codes": []
}
```
---
### POST /api/v1/users/me/totp/confirm
Confirm a pending TOTP enrollment.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass",
"code": "123456"
}
```
#### Response 204 No Content
---
### DELETE /api/v1/users/me/totp
Disable TOTP for the authenticated user.
**Auth:** Required
**Rate limit:** 5 requests/minute per IP
#### Request
```json
{
"password": "MyStr0ng!Pass"
}
```
#### Response 204 No Content
---
## Channel Endpoints
### GET /api/v1/channels
List all channels the authenticated user has `READ_MESSAGES` permission for. DM channels are NOT included (use `GET /api/v1/dms` instead).
**Auth:** Required
#### Response 200 OK
```json
[
{
"id": 1,
"name": "general",
"type": "text",
"topic": "Welcome to the server!",
"category": "Text Channels",
"position": 0,
"slow_mode": 0,
"archived": false
}
]
```
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | int64 | Channel ID |
| `name` | string | Channel name |
| `type` | string | `text`, `voice`, or `announcement` |
| `topic` | string | Channel topic/description |
| `category` | string | Category grouping |
| `position` | int | Sort order within category |
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
| `archived` | bool | Whether the channel is archived |
---
### GET /api/v1/channels/{id}/messages
Paginated message history for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel (or DM participant membership)
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
| `limit` | int | 50 | 1-100 | Number of messages to return |
#### Response 200 OK
```json
{
"messages": [
{
"id": 1042,
"channel_id": 5,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png"
},
"content": "Hello!",
"reply_to": null,
"attachments": [
{
"id": "file-uuid",
"filename": "photo.jpg",
"size": 204800,
"mime_type": "image/jpeg",
"url": "/api/v1/files/file-uuid",
"width": 1920,
"height": 1080
}
],
"reactions": [
{
"emoji": "\ud83d\udc4d",
"count": 2,
"me": true
}
],
"pinned": false,
"edited_at": null,
"deleted": false,
"timestamp": "2026-03-14T10:30:00Z"
}
],
"has_more": true
}
```
#### Pagination
Use cursor-based pagination by passing the `id` of the last message as the `before` parameter:
```
GET /api/v1/channels/5/messages?before=1042&limit=50
```
When `has_more` is `false`, you have reached the beginning of the channel history.
---
### GET /api/v1/channels/{id}/pins
Get all pinned messages for a channel.
**Auth:** Required
**Permission:** `READ_MESSAGES` on the channel
#### Response 200 OK
Returns `{ "messages": [...], "has_more": false }`. `has_more` is always `false` for pins (all pinned messages are returned at once).
---
### POST /api/v1/channels/{id}/pins/{messageId}
Pin a message in a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
### DELETE /api/v1/channels/{id}/pins/{messageId}
Unpin a message from a channel.
**Auth:** Required
**Permission:** `MANAGE_MESSAGES` on the channel
#### Response 204 No Content
---
## Search
### GET /api/v1/search
Full-text search across messages in channels the user can read. Uses SQLite FTS5 for matching.
**Auth:** Required
#### Query Parameters
| Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- |
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
| `limit` | int | 50 | 1-100 | Maximum results to return |
#### Response 200 OK
```json
{
"results": [
{
"message_id": 1042,
"channel_id": 5,
"channel_name": "general",
"user": {
"id": 1,
"username": "alex"
},
"content": "...matched text...",
"timestamp": "2026-03-14T10:30:00Z"
}
]
}
```
---
## Direct Messages
DM channels use participant-based authorization rather than role-based permissions.
### POST /api/v1/dms
Create or retrieve a 1-on-1 DM channel with another user. If a DM channel already exists, it is returned and re-opened.
**Auth:** Required
#### Request
```json
{
"recipient_id": 2
}
```
#### Response 200 OK (existing channel) or 201 Created (new channel)
```json
{
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
},
"created": false
}
```
---
### GET /api/v1/dms
List all open DM channels for the authenticated user, ordered by most recent activity.
**Auth:** Required
#### Response 200 OK
```json
{
"dm_channels": [
{
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
},
"last_message_id": 5042,
"last_message": "Hey, how's it going?",
"last_message_at": "2026-03-28T14:30:00Z",
"unread_count": 3
}
]
}
```
---
### DELETE /api/v1/dms/{channelId}
Close a DM channel for the authenticated user (hides it from their sidebar). The channel and messages remain in the database. If the other user sends a new message, the channel is automatically re-opened.
**Auth:** Required
#### Response 204 No Content
---
## Invite Endpoints
All invite endpoints require authentication and the `MANAGE_INVITES` permission.
### POST /api/v1/invites
Create a new invite code.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Request
```json
{
"max_uses": 5,
"expires_in_hours": 48
}
```
Both fields are optional. An empty body creates an invite with unlimited uses and no expiry.
#### Response 201 Created
```json
{
"id": 1,
"code": "abc123def",
"max_uses": 5,
"uses": 0,
"expires_at": "2026-03-30T10:30:00Z",
"revoked": false,
"created_at": "2026-03-28T10:30:00Z"
}
```
---
### GET /api/v1/invites
List all invites (active, expired, and revoked).
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 200 OK
Returns a JSON array of invite objects.
---
### DELETE /api/v1/invites/{code}
Revoke an invite by its code string.
**Auth:** Required
**Permission:** `MANAGE_INVITES`
#### Response 204 No Content
---
## File Upload and Serving
### POST /api/v1/uploads
Upload a file as multipart form data.
**Auth:** Required
**Body size limit:** 100 MiB
**Content-Type:** `multipart/form-data`
Files are validated against blocked magic bytes (PE executables, ELF binaries, Mach-O binaries, shell scripts). Files are stored with UUID filenames.
#### Response 201 Created
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/api/v1/files/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"width": 1920,
"height": 1080
}
```
`width` and `height` are only present for image files.
---
### GET /api/v1/files/{id}
Serve a previously uploaded file by its UUID.
**Auth:** None (URLs are unguessable UUIDs)
**Caching:** `Cache-Control: public, max-age=31536000, immutable`
Supports HTTP range requests and conditional requests.
---
## Health Check
### GET /health
### GET /api/v1/health
Public health check endpoint, no authentication required.
```json
{
"status": "ok",
"version": "1.0.0",
"uptime": 86400,
"online_users": 3
}
```
---
## Server Info
### GET /api/v1/info
Returns the server name and version.
**Auth:** None
```json
{
"name": "My OwnCord Server",
"version": "1.2.0"
}
```
---
## Metrics
### GET /api/v1/metrics
Runtime server metrics. Restricted to admin-allowed CIDRs.
**Auth:** Admin IP restriction (not token-based)
```json
{
"uptime": "2h30m15s",
"uptime_seconds": 9015.0,
"goroutines": 42,
"heap_alloc_mb": 12.5,
"heap_sys_mb": 24.0,
"num_gc": 156,
"connected_users": 8,
"livekit_healthy": true
}
```
---
## LiveKit Endpoints
These endpoints are only registered when LiveKit voice is configured.
### POST /api/v1/livekit/webhook
LiveKit webhook receiver. Uses LiveKit JWT verification. Admin-IP-restricted. Called by the LiveKit server, not by clients.
### GET /api/v1/livekit/health
Check whether the LiveKit server is reachable.
**Auth:** Admin IP restriction
#### Response 200 OK
```json
{
"status": "ok",
"livekit_reachable": true
}
```
#### Response 503 Service Unavailable
```json
{
"status": "degraded",
"livekit_reachable": false,
"error": "connection refused"
}
```
### /livekit/* (Reverse Proxy)
All requests to `/livekit/*` are reverse-proxied to the LiveKit server URL. The `/livekit` prefix is stripped before forwarding. This allows the client to connect to LiveKit through OwnCord's HTTPS server, avoiding mixed-content blocks.
**Auth:** None (LiveKit handles its own JWT-based auth)
**Rate limit:** 30 requests/minute per IP
---
## Diagnostics
### GET /api/v1/diagnostics/connectivity
Returns connectivity diagnostics for debugging voice/network issues.
**Auth:** Required (any authenticated user)
**Rate limit:** 5 requests/minute per user
```json
{
"server": {
"version": "1.0.0",
"uptime_s": 3600,
"go_version": "go1.23.0",
"online_users": 5
},
"voice": {
"enabled": true,
"livekit_url": "ws://localhost:7880",
"livekit_health": true,
"node_ip": "203.0.113.1",
"proxy_path": "/livekit"
},
"client": {
"remote_addr": "192.168.1.100",
"is_private_network": true
}
}
```
---
## Client Auto-Update
### GET /api/v1/client-update/{target}/{current_version}
Tauri-compatible update endpoint. The desktop client checks this to see if a newer version is available.
**Auth:** None
#### Path Parameters
| Param | Type | Description |
| ----- | ---- | ----------- |
| `target` | string | Platform target (e.g., `windows-x86_64`) |
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
#### Response 200 OK (update available)
```json
{
"version": "1.2.0",
"notes": "## What's Changed\n...",
"pub_date": "2026-03-28T00:00:00Z",
"platforms": {
"windows-x86_64": {
"signature": "base64-encoded-signature",
"url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/OwnCord_1.2.0_x64-setup.nsis.zip"
}
}
}
```
#### Response 204 No Content
Client is already up-to-date.
---
## WebSocket
### GET /api/v1/ws
WebSocket upgrade endpoint. Authentication is performed in-band (first message must be an `auth` frame with the session token). See [protocol.md](protocol.md) for the full WebSocket message protocol.
+280
View File
@@ -0,0 +1,280 @@
# Client Architecture: Tauri v2
Comprehensive architecture reference for the OwnCord Tauri v2 desktop client. Covers project structure, data flow, component system, and subsystems.
## Why Tauri v2
Tauri v2 uses the OS webview (WebView2 on Windows) so the install is ~10-15 MB and RAM usage is ~30-50 MB. The HTML/CSS mockups become the actual UI code, with CSS handling hover effects, conditional visibility, theming, and animations.
---
## Project Layout
```text
Client/tauri-client/
├── src-tauri/ # Rust backend
│ ├── Cargo.toml
│ ├── tauri.conf.json # Window size, title, plugins, CSP, updater
│ └── src/
│ ├── main.rs # Windows entry point
│ ├── lib.rs # Tauri Builder: plugins, commands, state
│ ├── credentials.rs # Win Credential Manager (DPAPI)
│ ├── commands.rs # Settings store, cert fingerprints, DevTools
│ ├── ws_proxy.rs # WSS proxy with TOFU cert pinning
│ ├── livekit_proxy.rs # TCP-to-TLS tunnel for LiveKit signaling
│ ├── ptt.rs # Push-to-talk via GetAsyncKeyState
│ ├── tray.rs # System tray icon and menu
│ ├── hotkeys.rs # Global shortcut registration
│ └── update_commands.rs # Auto-update check + install
├── src/ # TypeScript frontend
│ ├── index.html # Single HTML entry point
│ ├── main.ts # Bootstrap, router, service wiring
│ │
│ ├── styles/
│ │ ├── tokens.css # CSS custom properties
│ │ ├── base.css # Reset, scrollbar, typography
│ │ ├── login.css # ConnectPage styles
│ │ ├── app.css # MainPage + component styles
│ │ └── theme-neon-glow.css # Default theme overrides
│ │
│ ├── lib/ # Core services (no UI, no DOM)
│ │ ├── api.ts # REST client (Tauri plugin-http)
│ │ ├── ws.ts # WebSocket client (Tauri IPC proxy)
│ │ ├── types.ts # Protocol types (WS + REST + permissions)
│ │ ├── store.ts # Reactive store factory
│ │ ├── dispatcher.ts # WS message -> store action router
│ │ ├── router.ts # In-memory page router
│ │ ├── livekitSession.ts # LiveKit voice/video session
│ │ ├── connectionStats.ts # WebRTC stats poller
│ │ ├── rate-limiter.ts # Sliding-window rate limiter
│ │ ├── permissions.ts # Bitfield utilities
│ │ ├── profiles.ts # Server profile CRUD
│ │ ├── credentials.ts # Credential storage (Tauri IPC)
│ │ ├── disposable.ts # Component lifecycle cleanup
│ │ ├── dom.ts # XSS-safe DOM helpers
│ │ ├── safe-render.ts # Error boundary
│ │ ├── logger.ts # Structured logger
│ │ ├── notifications.ts # Desktop notifications
│ │ ├── tenor.ts # Tenor GIF API v2
│ │ ├── themes.ts # Theme manager
│ │ ├── updater.ts # Auto-update
│ │ ├── reconcile.ts # Keyed DOM list reconciliation
│ │ ├── icons.ts # Lucide SVG icon factory
│ │ └── ...
│ │
│ ├── stores/ # Reactive state stores
│ │ ├── auth.store.ts
│ │ ├── channels.store.ts
│ │ ├── dm.store.ts
│ │ ├── messages.store.ts
│ │ ├── members.store.ts
│ │ ├── voice.store.ts
│ │ └── ui.store.ts
│ │
│ ├── components/ # UI components
│ │ ├── MessageList.ts, MessageInput.ts, ...
│ │ ├── message-list/ # MessageList sub-modules
│ │ └── settings/ # Settings tab components
│ │
│ └── pages/
│ ├── ConnectPage.ts # Login/register page
│ ├── MainPage.ts # Main app layout
│ └── main-page/ # MainPage sub-controllers
│ ├── SidebarArea.ts
│ ├── ChatArea.ts
│ ├── ChannelController.ts
│ ├── MessageController.ts
│ └── ...
├── tests/
│ ├── unit/ # Vitest unit tests
│ ├── integration/ # Vitest with mocked WS
│ └── e2e/ # Playwright E2E tests
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
└── playwright.config.ts
```
---
## Architecture Layers
```text
+===================================================================+
| UI Components |
| (HTML + CSS, vanilla TypeScript DOM manipulation) |
| Components are factory functions returning { mount, destroy } |
+===================================================================+
| | |
| subscribe() | actions | events
v v v
+===================================================================+
| Reactive Stores |
| auth | channels | dm | messages | members | voice | ui |
| Immutable state. Batched notifications via queueMicrotask. |
+===================================================================+
^ |
| WS events | send()
+===================================================================+
| Core Services |
| ws.ts api.ts dispatcher.ts rate-limiter.ts |
| livekitSession.ts notifications.ts ptt.ts tenor.ts |
+===================================================================+
| |
| invoke() | listen()
v v
+===================================================================+
| Tauri IPC Bridge |
+===================================================================+
| ^
v |
+===================================================================+
| Rust Backend |
| ws_proxy (WSS + TOFU) livekit_proxy (TCP-to-TLS tunnel) |
| credentials (Win32 DPAPI) ptt (GetAsyncKeyState polling) |
| commands (settings store) tray hotkeys update_commands |
+===================================================================+
```
Data flows DOWN through layers. Events flow UP via subscriptions. No component directly calls the WebSocket or REST API; they go through stores and controllers.
---
## Rust Backend Modules
### ws_proxy.rs -- WebSocket Proxy with TOFU
WebView2 rejects self-signed TLS certificates. All WebSocket traffic routes through Rust. The Rust proxy implements TOFU certificate pinning -- on first connect, the cert fingerprint is stored; on subsequent connects, it is verified.
### livekit_proxy.rs -- LiveKit TLS Tunnel
A local TCP listener proxies LiveKit SDK connections through TLS to the remote server, avoiding self-signed cert issues.
### credentials.rs -- Windows Credential Manager
Uses Win32 Credential Manager APIs. Credentials are stored as DPAPI-encrypted blobs tied to the Windows user account.
### ptt.rs -- Push-to-Talk
Uses `GetAsyncKeyState` for non-consuming key detection. 20ms polling loop on a background thread.
### tray.rs -- System Tray
System tray icon with Show/Hide, Status submenu, and Quit.
### update_commands.rs -- Auto-Update
Dynamic server URL updater endpoint. Update artifacts are verified via Ed25519 signature.
---
## Store System
The store factory (`createStore`) provides `getState`, `setState`, `subscribe`, `subscribeSelector`, `select`, and `flush`. State is always immutable. Notifications are batched via `queueMicrotask`.
### Store Responsibilities
| Store | Key State | WS Events Handled |
|-------|-----------|-------------------|
| **auth** | token, user, serverName, motd, isAuthenticated | `auth_ok`, `auth_error` |
| **channels** | channels (Map), activeChannelId | `ready`, `channel_create/update/delete` |
| **dm** | DM channel list | `dm_channel_open`, `dm_channel_close` |
| **messages** | per-channel messages, pending sends, hasMore | `chat_message`, `chat_edited`, `chat_deleted`, `chat_send_ok`, `reaction_update` |
| **members** | member Map, typing indicators | `ready`, `member_join/leave/update/ban`, `typing`, `presence` |
| **voice** | currentChannelId, voice users, local audio state | `voice_state`, `voice_leave`, `voice_config`, `voice_token` |
| **ui** | sidebar mode, modals, theme, connection status | `server_restart`, `error` |
Messages per channel are capped at 500. Typing indicators auto-clear after 5 seconds.
---
## Component System
Components are factory functions returning `{ mount, destroy }`. `mount()` appends elements to a container; `destroy()` removes DOM, unsubscribes listeners, and clears intervals.
### DOM List Reconciliation
For efficient list updates (member list, channel list), a keyed reconciliation algorithm reuses existing DOM elements, updates in place, and removes stale elements -- preserving hover states, focus, and scroll position.
---
## Sidebar Architecture
```text
+----------------------------------+
| SERVER HEADER |
+----------------------------------+
| DIRECT MESSAGES (3) [+] |
| Top 3 DMs with unread badges |
| View all messages link |
+----------------------------------+
| TEXT CHANNELS |
| Category-grouped, collapsible |
+----------------------------------+
| VOICE CHANNELS |
| User avatars in channel |
+----------------------------------+
| MEMBERS (collapsible) |
| Role-grouped, drag-to-resize |
+----------------------------------+
| VOICE WIDGET |
| Mute/deafen/camera/screen/leave |
+----------------------------------+
| USER BAR |
| Settings + quick-switch buttons |
+----------------------------------+
```
Two sidebar modes: **"channels"** (full server view) and **"dms"** (full DM conversations list).
---
## Chat Area Architecture
The chat area composes: chat header, message list, typing indicator, message input, video grid (overlays when cameras are active), pinned messages panel, and search overlay.
The `ChannelController` manages mounting/destroying per-channel components when the active channel changes.
---
## Voice and Video (Client Side)
### LiveKit Session
The `LiveKitSession` class manages the full voice/video lifecycle via LiveKit's `livekit-client` JS SDK.
**Stream Quality Presets:**
| Preset | Camera Resolution | Camera Bitrate | Screen Resolution | Screen Bitrate |
|--------|------------------|----------------|-------------------|----------------|
| low | 360p | 600 Kbps | 720p@5fps | 1.5 Mbps |
| medium | 720p | 1.7 Mbps | 1080p@15fps | 3 Mbps |
| high | 1080p | 4 Mbps | 1080p@30fps | 6 Mbps |
| source | 1080p | 8 Mbps | native | 10 Mbps |
### Connection Quality
A 2-second polling interval collects WebRTC stats from both publisher and subscriber PeerConnections. Quality is color-coded: green (<100ms), yellow (100-200ms), red (>200ms).
---
## REST API Client
Uses `@tauri-apps/plugin-http` fetch (not browser fetch) to bypass self-signed cert rejection. All requests include `danger: { acceptInvalidCerts: true }` for server URLs only. Third-party fetches use standard cert validation.
---
## Dispatcher
`wireDispatcher(ws)` attaches listeners to the WsClient, routing each server message type to the appropriate store actions. Key mappings:
- `ready` -> sets channels, members, voice states, DM channels
- `chat_message` -> adds message, increments unread, triggers notifications
- `voice_token` -> starts LiveKit session
- `presence` -> updates member status
- `server_restart` -> shows warning banner
See [protocol.md](protocol.md) for complete message type reference.
+88
View File
@@ -0,0 +1,88 @@
# Contributing
How to set up the development environment and contribute to OwnCord.
## Development Setup
### Prerequisites
- **Windows 10+** (x64)
- **Go 1.22+** (server)
- **Node.js 20+** (client)
- **Rust / Cargo** (Tauri client)
### Available Commands
#### Server (Go)
| Command | Description |
|---------|-------------|
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary |
| `go test ./...` | Run all server tests |
| `go test ./... -cover` | Run server tests with coverage |
| `go test -race ./...` | Run server tests with race detection |
#### Client (Tauri v2)
| Command | Description |
|---------|-------------|
| `npm run dev` | Start Vite dev server with hot reload |
| `npm run build` | TypeScript check + Vite production build |
| `npm run tauri dev` | Launch Tauri app in dev mode |
| `npm run tauri build` | Build release installer |
| `npm test` | Run all tests (vitest) |
| `npm run test:unit` | Unit tests only |
| `npm run test:integration` | Integration tests only |
| `npm run test:e2e` | Playwright E2E (mocked Tauri) |
| `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) |
| `npm run test:e2e:prod` | Playwright E2E (prod build) |
| `npm run test:e2e:ui` | Playwright UI mode |
| `npm run test:watch` | Vitest watch mode |
| `npm run test:coverage` | Coverage report |
| `npm run typecheck` | Full typecheck (all sources) |
| `npm run lint` | ESLint check (src/) |
| `npm run lint:fix` | ESLint auto-fix |
## Active Branches
- `main` -- stable releases
- `dev` -- active development
## Branch Naming
- `feature/<name>` -- new features
- `fix/<name>` -- bug fixes
- `docs/<name>` -- documentation changes
## Commit Format
Use conventional commits:
```text
feat: add thread support to channels
fix: prevent duplicate WebSocket connections
refactor: extract permission checks into middleware
docs: update quick-start guide
test: add integration tests for invite flow
chore: bump Go dependencies
perf: cache role permissions in memory
ci: add lint step to GitHub Actions
```
## Pull Request Process
1. Branch from `dev` (the active development branch)
2. PRs target `dev`; `main` is for stable releases only
3. CI must pass (build + test + lint)
4. Request code review
5. Squash merge preferred
## Testing
Target **80%+ coverage**. Follow test-driven development workflow.
## Code Style
- **TypeScript**: See [Client Architecture](client-architecture.md)
- **Go**: `gofmt` + `golangci-lint`, standard library preferred
- **Rust**: `cargo fmt` + `cargo clippy`, minimal code (native APIs only)
+246
View File
@@ -0,0 +1,246 @@
# Deployment Guide
Production deployment guide for OwnCord server on Windows.
## Prerequisites
- **Windows 10+** (x64)
- **Go 1.22+** (only if building from source)
- **LiveKit Server** binary (for voice/video) -- see [LiveKit Setup](livekit-setup.md)
- Ports available: `8443` (default), `7880` (LiveKit), `80` (if using ACME/Let's Encrypt)
## Building from Source
```bash
cd Server
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
```
- `-s -w` strips debug info (smaller binary)
- `-X main.version=...` embeds the version string
Alternatively, download a pre-built `chatserver.exe` from GitHub Releases.
## First Run Behavior
When `chatserver.exe` starts for the first time:
1. **Config creation** -- `config.yaml` is written to the working directory with defaults
2. **Data directory** -- `data/` is created (database, certs, uploads, backups)
3. **TLS certificate** -- A self-signed certificate is generated at `data/cert.pem` / `data/key.pem`
4. **Database migration** -- SQLite database is created and all migrations run
5. **Status reset** -- All user statuses are set to `offline`, stale voice states are cleared
6. **Admin setup page** -- Navigate to `https://localhost:8443/admin` to create the Owner account
The server listens on `https://0.0.0.0:8443` by default. See [Server Configuration](server-configuration.md) for all options.
## Running as a Windows Service
### Option 1: NSSM (Non-Sucking Service Manager)
```powershell
# Install NSSM (via Chocolatey or download from nssm.cc)
choco install nssm
# Create service
nssm install OwnCord "C:\OwnCord\chatserver.exe"
nssm set OwnCord AppDirectory "C:\OwnCord"
nssm set OwnCord DisplayName "OwnCord Chat Server"
nssm set OwnCord Start SERVICE_AUTO_START
# Manage
nssm start OwnCord
nssm stop OwnCord
nssm restart OwnCord
```
### Option 2: Task Scheduler
1. Open Task Scheduler, create a new task
2. Trigger: **At startup**
3. Action: Start `chatserver.exe`
4. Set "Start in" to the directory containing `config.yaml`
5. Check "Run whether user is logged on or not"
6. Check "Run with highest privileges"
## TLS Setup
### Self-Signed (default)
Auto-generated on first run. The Tauri client uses TOFU pinning to accept the cert on first connect.
```yaml
tls:
mode: "self_signed"
```
### Let's Encrypt (ACME)
Automatic certificate issuance and renewal. Requires port 80 open and a public domain.
```yaml
tls:
mode: "acme"
domain: "chat.example.com"
acme_cache_dir: "data/acme_certs"
```
### Manual Certificate
Use your own certificate files:
```yaml
tls:
mode: "manual"
cert_file: "path/to/cert.pem"
key_file: "path/to/key.pem"
```
### TLS Off
Not recommended. For development or when behind a TLS-terminating reverse proxy:
```yaml
tls:
mode: "off"
```
## Backup Strategy
### SQLite WAL Considerations
The database uses SQLite WAL mode. Do NOT copy the `.db` file directly while the server is running -- use the backup endpoint instead.
### Admin Backup Endpoint
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/admin/api/backups` | POST | Create a new backup |
| `/admin/api/backups` | GET | List all backups (newest first) |
| `/admin/api/backups/{name}` | DELETE | Delete a backup |
| `/admin/api/backups/{name}/restore` | POST | Restore from backup (creates pre-restore safety backup first) |
Backups are stored in `data/backups/` with timestamps.
### Scheduled Backups
Use Windows Task Scheduler with PowerShell:
```powershell
$headers = @{ "Cookie" = "session=<admin-session-token>" }
Invoke-RestMethod -Uri "https://localhost:8443/admin/api/backups" -Method POST -Headers $headers -SkipCertificateCheck
```
### Restore
Restoring replaces the live database file. A pre-restore safety backup is created automatically. A server restart is recommended after restore.
## Monitoring
### Health Endpoint
`GET /health` -- public, no authentication required.
```json
{
"status": "ok",
"version": "1.0.0",
"uptime": 86400,
"online_users": 12
}
```
### Metrics Endpoint
`GET /api/v1/metrics` -- admin IP restricted.
```json
{
"uptime": "24h0m0s",
"uptime_seconds": 86400,
"goroutines": 42,
"heap_alloc_mb": 15.3,
"heap_sys_mb": 24.0,
"num_gc": 150,
"connected_users": 12,
"voice_sessions": 3,
"livekit_healthy": true
}
```
### LiveKit Health
`GET /api/v1/livekit/health` -- checks LiveKit companion process reachability.
### Diagnostics
`GET /api/v1/diagnostics/connectivity` -- connectivity diagnostics for troubleshooting.
## Auto-Update
### Server
The server checks GitHub Releases for updates:
- Compares semver versions
- Results are cached for 1 hour
- Downloads `chatserver.exe` with SHA256 checksum verification
- On restart, the old binary is cleaned up
Set `github.token` in config for higher API rate limits (5000/hr vs 60/hr unauthenticated).
### Client
The Tauri client uses NSIS installer updates:
- Server exposes client update assets from GitHub Releases
- Ed25519 signature verification before applying
## Firewall and Ports
| Port | Protocol | Purpose |
|------|----------|---------|
| `8443` | TCP | HTTPS server (configurable via `server.port`) |
| `80` | TCP | ACME HTTP-01 challenge (only if `tls.mode: acme`) |
| `7880` | TCP | LiveKit server (WebSocket signaling) |
| `7881` | TCP | LiveKit server (RTC/TURN over TCP) |
| `50000-60000` | UDP | LiveKit WebRTC media (ICE candidates) |
For remote access, see the [Port Forwarding Guide](port-forwarding.md) or [Tailscale Guide](tailscale.md).
## Hardening Checklist
- [ ] **Change default admin password** -- create a strong Owner password during setup
- [ ] **Set `admin_allowed_cidrs`** -- restrict admin access to specific IPs if needed
- [ ] **Enable TLS** -- use `acme` or `manual` mode; avoid `off` in production
- [ ] **Set `allowed_origins`** -- restrict WebSocket origins to your domain
- [ ] **Set `trusted_proxies`** -- configure if behind a reverse proxy
- [ ] **Set stable voice credentials** -- set `livekit_api_key` and `livekit_api_secret` to avoid token breakage on restart
- [ ] **Set `voice.node_ip`** -- required for remote users behind NAT
- [ ] **Review upload limits** -- adjust `upload.max_size_mb` for your use case
- [ ] **Configure GitHub token** -- optional, for reliable update checks
- [ ] **Schedule backups** -- use the admin backup endpoint on a cron schedule
- [ ] **Monitor health** -- poll `/health` for uptime monitoring
## Background Maintenance
The server runs a maintenance loop every 15 minutes that:
- Purges expired user sessions
- Deletes orphaned file attachments (uploaded but never linked to a message, older than 1 hour)
- Uses a circuit breaker (pauses after 5 consecutive failures)
## Graceful Shutdown
The server handles `Ctrl+C` (SIGINT) and `SIGTERM`:
1. Stops accepting new connections
2. Closes all WebSocket connections and voice rooms
3. Drains HTTP connections with a 30-second timeout
4. Stops the maintenance loop
5. Closes the database
## See Also
- [Server Configuration](server-configuration.md) -- full config key reference
- [LiveKit Setup](livekit-setup.md) -- voice/video setup
- [Quick Start](quick-start.md) -- getting started
- [Port Forwarding](port-forwarding.md) -- port forwarding for remote access
- [Tailscale](tailscale.md) -- zero-config networking
- [Security](security.md) -- security guidelines
+132
View File
@@ -0,0 +1,132 @@
# LiveKit Setup Guide
LiveKit is an open-source SFU (Selective Forwarding Unit) that handles real-time voice and video. OwnCord uses it instead of rolling its own WebRTC stack -- LiveKit handles all the hard parts (DTLS, ICE, codec negotiation, simulcast) while OwnCord manages permissions, state, and room lifecycle.
---
## 1. Get the LiveKit Binary
Download `livekit-server` for Windows from one of:
- **GitHub releases**: <https://github.com/livekit/livekit/releases>
- Grab the `livekit-server_*_windows_amd64.zip` asset
- **LiveKit website**: <https://livekit.io/> (Docs > Self Hosting)
Extract the binary somewhere permanent (e.g. `C:\livekit\livekit-server.exe`).
---
## 2. Server Configuration
LiveKit settings live in the `voice:` section of `config.yaml`:
```yaml
voice:
livekit_api_key: "devkey"
livekit_api_secret: "owncord-dev-secret-key-min-32chars"
livekit_url: "ws://localhost:7880"
livekit_binary: "C:/livekit/livekit-server.exe"
quality: "medium"
```
| Field | Purpose | Default |
|-------|---------|---------|
| `livekit_api_key` | Shared API key between OwnCord and LiveKit | `"devkey"` |
| `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` |
| `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` |
| `livekit_binary` | Path to `livekit-server` binary. Empty = assume externally managed | `""` (disabled) |
| `quality` | Default voice quality preset | `"medium"` |
Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc.
> **Warning**: The server logs a warning at startup if you use the default dev key/secret. Always change these for production.
---
## 3. Ports and Firewall
| Port | Protocol | Purpose |
|------|----------|---------|
| **7880** | TCP (HTTP/WS) | LiveKit signaling (WebSocket + REST API) |
| **7881** | TCP | LiveKit internal RTC (TURN/TCP fallback) |
| **50000-60000** | UDP | Media transport (RTP audio/video) |
For LAN-only setups, ensure these ports are open on Windows Firewall. For remote access, forward these through your router or use [Tailscale](tailscale.md).
---
## 4. How the Companion Process Works
When `livekit_binary` is set, OwnCord manages LiveKit as a companion process:
1. **Config generation**: OwnCord auto-generates `data/livekit.yaml` with the API key/secret, port 7880, and UDP range 50000-60000
2. **Process launch**: `livekit-server --config data/livekit.yaml`
3. **Crash recovery**: Exponential backoff restart (3s -> 6s -> 12s ... up to 60s), gives up after 10 consecutive rapid failures
4. **Health checks**: `GET http://localhost:7880/` verifies LiveKit is responding
5. **Graceful shutdown**: Stops the process when OwnCord shuts down (5s timeout before kill)
If `livekit_binary` is empty, OwnCord assumes LiveKit is managed externally (e.g. Docker, systemd, or manual start).
---
## 5. Token Flow
How a client joins voice:
```
Client OwnCord Server LiveKit Server
| | |
|-- voice_join (channel_id)-->| |
| |-- check CONNECT_VOICE |
| |-- persist to voice_states |
| |-- GenerateToken() |
|<-- voice_token ------------| |
| { token, url, | |
| direct_url } | |
| | |
|-- connect with JWT --------|-------------------------->|
|<--- media streams ----------|--------------------------|
```
**Token details:**
- Room name: `"channel-{channelID}"`
- Identity: `"user-{userID}"`
- TTL: 24 hours (refresh at 23h)
- `canPublish` is derived from the `SPEAK_VOICE` permission
- `canSubscribe` is always true
- Client can request refresh via `voice_token_refresh` (rate limited to 1/60s)
**Client connection paths:**
- **Proxy path** (`/livekit`): Client connects through OwnCord's HTTPS server. Avoids mixed-content issues.
- **Direct URL** (`ws://localhost:7880`): Used when the client is on localhost.
---
## 6. Webhook Integration
LiveKit sends webhooks to `POST /api/v1/livekit/webhook`. The endpoint verifies the JWT and handles `participant_left` to clean up ghost voice states when a user disconnects from LiveKit without sending a `voice_leave` message.
---
## 7. Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| "voice not configured" error | LiveKit client failed to initialize | Check `livekit_api_key` and `livekit_api_secret` are set and secret is >= 32 chars |
| "failed to generate voice token" | API key/secret mismatch | Ensure `config.yaml` key/secret match what LiveKit is using |
| Voice connects but no audio | Firewall blocking UDP 50000-60000 | Open UDP port range in Windows Firewall |
| "backend unavailable" from `/livekit` proxy | LiveKit not running on port 7880 | Check `livekit_binary` path or start LiveKit manually |
| "too many rapid failures, giving up" in logs | LiveKit binary crashes on startup | Run `livekit-server --config data/livekit.yaml` manually to see errors |
| Mixed content / insecure WS error | Client using direct URL over HTTPS page | Client should use the `/livekit` proxy path |
| `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` |
---
## 8. Production Checklist
- [ ] Change `livekit_api_key` from `"devkey"` to a random string
- [ ] Change `livekit_api_secret` to a random 32+ character string
- [ ] Open firewall ports: 7880/TCP, 50000-60000/UDP
- [ ] If using ACME/manual TLS, ensure LiveKit proxy at `/livekit` is working
- [ ] Test voice by joining a voice channel from two clients
- [ ] Check `/api/v1/livekit/health` returns `{"status": "ok"}`
+32
View File
@@ -0,0 +1,32 @@
# Port Forwarding Guide
How to make your OwnCord server accessible to friends outside your local network.
## Why
Friends outside your LAN need a way to reach your server. Port forwarding tells your router to send incoming traffic on a specific port to your server machine.
## Steps
1. **Find your router's admin page** -- usually `192.168.1.1` or `192.168.0.1`. Check your gateway IP with `ipconfig` (Windows) or `ip route` (Linux).
2. **Find the port forwarding section** -- may be listed under "NAT", "Virtual Servers", or "Firewall" depending on your router.
3. **Add a rule for the server:**
- External port: `8443`
- Internal IP: your server machine's local IP
- Internal port: `8443`
- Protocol: TCP
4. **Add a rule for voice chat** (if using voice/video):
- External port: `3478`
- Internal IP: your server machine's local IP
- Internal port: `3478`
- Protocol: UDP
5. **Find your public IP** at a site like `whatismyip.com`.
6. **Share your public IP and port** with friends: `your.public.ip:8443`
## Troubleshooting
Windows Firewall may block incoming connections. `chatserver.exe` should prompt on first run to allow access. If not, manually add a firewall rule for port 8443 (TCP) and 3478 (UDP).
## Dynamic IP
If your public IP changes frequently, consider a Dynamic DNS service (e.g., No-IP, DuckDNS) so friends can use a stable hostname instead of a raw IP address.
+838
View File
@@ -0,0 +1,838 @@
# WebSocket Protocol Reference
All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`.
**Related docs:**
- [api.md](api.md) -- REST endpoints (message history, file uploads, etc.)
- [schema.md](schema.md) -- Database tables and permission bitfields
---
## Table of Contents
1. [Transport Layer](#transport-layer)
2. [Message Envelope](#message-envelope)
3. [Sequence Numbers](#sequence-numbers)
4. [Authentication Flow](#authentication-flow)
5. [Heartbeat and Connection Liveness](#heartbeat-and-connection-liveness)
6. [Reconnection with State Recovery](#reconnection-with-state-recovery)
7. [Initial State (ready)](#initial-state-ready)
8. [Chat Messages](#chat-messages)
9. [Reactions](#reactions)
10. [Typing Indicators](#typing-indicators)
11. [Presence](#presence)
12. [Channel Focus](#channel-focus)
13. [Channel Updates](#channel-updates)
14. [Member Updates](#member-updates)
15. [Voice Signaling](#voice-signaling)
16. [Direct Messages](#direct-messages)
17. [Server Restart](#server-restart)
18. [Error Handling](#error-handling)
19. [Rate Limits](#rate-limits)
20. [Message Type Reference Table](#message-type-reference-table)
---
## Transport Layer
### WebSocket Endpoint
```
wss://{host}/api/v1/ws
```
The client connects via the Tauri Rust backend's WS proxy rather than native WebView2 WebSocket. This is required because WebView2 rejects self-signed TLS certificates. The Rust proxy uses TOFU (Trust On First Use) certificate pinning.
### Transport Limits
| Limit | Value |
|-------|-------|
| Max read size | 1 MB |
| Max message content | 4000 runes |
| Write timeout | 10 seconds |
| Auth deadline | 10 seconds |
| Send buffer per client | 256 messages |
---
## Message Envelope
Every WebSocket message is a JSON object with these fields:
```json
{
"type": "message_type",
"id": "unique-request-id",
"payload": { },
"seq": 42
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | Determines how `payload` is interpreted |
| `id` | string | Client messages only | Client-generated UUID for request/response correlation |
| `payload` | object | Yes | Contents vary by `type`. Must be present (can be `{}`). |
| `seq` | uint64 | Broadcast messages only | Monotonically increasing sequence number. Only present on server-to-client broadcast messages. |
---
## Sequence Numbers
The sequence number system enables reconnection with state recovery.
1. The server maintains an atomic `uint64` counter.
2. Every broadcast message gets the next seq number.
3. The message is stored in a 1000-event replay ring buffer.
4. The client tracks `lastSeq` from every server broadcast.
### Which Messages Get seq
| Category | Has seq? | Examples |
|----------|----------|---------|
| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` |
| Global broadcasts | Yes | `presence`, `member_join`, `member_leave`, `member_update`, `member_ban`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` |
| Ephemeral | No | `typing` |
| DM messages | No | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update`, `dm_channel_open`, `dm_channel_close` |
| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` |
---
## Authentication Flow
### Step 1: Client Sends auth
After the WebSocket connection is established, the client sends the first message within 10 seconds:
```json
{
"type": "auth",
"payload": {
"token": "session-token-from-login",
"last_seq": 0
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` |
| `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. |
### Step 2: Success -- auth_ok
```json
{
"type": "auth_ok",
"payload": {
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"role": "admin"
},
"server_name": "My Server",
"motd": "Welcome!"
}
}
```
### Step 3: Failure -- auth_error
```json
{
"type": "auth_error",
"payload": {
"message": "Invalid or expired token"
}
}
```
After sending `auth_error`, the server closes the connection.
### Step 4: ready Payload
After `auth_ok`, the server sends a `ready` message containing all initial state.
### Step 5: Member Join + Presence
The server broadcasts to all connected clients:
```json
{ "type": "member_join", "payload": { "user": { "id": 1, "username": "alex", "avatar": "uuid.png", "role": "admin" } } }
{ "type": "presence", "payload": { "user_id": 1, "status": "online" } }
```
### Periodic Session Revalidation
Every 10 messages, the server re-checks the session token against the database. If the session has been revoked, expired, or the user banned, the connection is closed immediately.
---
## Heartbeat and Connection Liveness
### Client Ping
The client sends a JSON ping every 30 seconds:
```json
{ "type": "ping", "payload": {} }
```
### Server Pong
The server responds immediately:
```json
{ "type": "pong" }
```
### Server Stale Client Sweep
Every 30 seconds, the server checks all clients. Any client with no activity for 90 seconds is forcibly disconnected. Normal chat activity also keeps the connection alive.
---
## Reconnection with State Recovery
When a connection drops, the client automatically reconnects with exponential backoff (1s to 30s max) and sends `last_seq` in the `auth` message.
| Condition | Server Behavior |
|-----------|-----------------|
| `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` |
| `last_seq > 0` AND seq in buffer | Replay flow: `auth_ok` + missed events + `presence` (no `member_join`, no `ready`) |
| `last_seq > 0` AND seq NOT in buffer | Full flow (fallback): same as `last_seq == 0` |
DM events are not stored in the ring buffer and are only recoverable via the full `ready` payload.
---
## Initial State (ready)
Sent once after `auth_ok` (fresh connection or replay fallback).
```json
{
"type": "ready",
"payload": {
"channels": [ ... ],
"dm_channels": [ ... ],
"members": [ ... ],
"voice_states": [ ... ],
"roles": [ ... ],
"server_name": "My Server",
"motd": "Welcome!"
}
}
```
### Payload Fields
**channels[]:** `id`, `name`, `type` (`text`/`voice`/`announcement`), `category`, `position`, `unread_count` (text only), `last_message_id` (text only)
**dm_channels[]:** `channel_id`, `recipient` (user object with `id`, `username`, `avatar`, `status`), `last_message_id`, `last_message`, `last_message_at`, `unread_count`
**members[]:** All registered users with `id`, `username`, `avatar`, `role` (lowercase name), `status`
**voice_states[]:** All users currently in any voice channel: `channel_id`, `user_id`, `muted`, `deafened`
**roles[]:** All server roles with `id`, `name`, `color`, `permissions` (bitfield)
---
## Chat Messages
### chat_send (Client -> Server)
```json
{
"type": "chat_send",
"id": "550e8400-e29b-41d4-a716-446655440000",
"payload": {
"channel_id": 5,
"content": "Hello everyone!",
"reply_to": null,
"attachments": ["upload-uuid-1"]
}
}
```
| Field | Type | Required | Constraints |
|-------|------|----------|-------------|
| `channel_id` | number | Yes | Positive integer |
| `content` | string | Yes* | Max 4000 runes. HTML-sanitized. *Can be empty if `attachments` is non-empty. |
| `reply_to` | number or null | No | Message ID being replied to |
| `attachments` | string[] | No | Upload IDs from `POST /api/v1/uploads`. Requires `ATTACH_FILES` permission. |
### chat_send_ok (Server -> Client)
Direct response to sender (no seq):
```json
{
"type": "chat_send_ok",
"id": "550e8400-e29b-41d4-a716-446655440000",
"payload": {
"message_id": 1042,
"timestamp": "2026-03-14T10:30:00Z"
}
}
```
### chat_message (Server -> Client, broadcast)
```json
{
"seq": 42,
"type": "chat_message",
"payload": {
"id": 1042,
"channel_id": 5,
"user": {
"id": 1,
"username": "alex",
"avatar": "uuid.png",
"role": "admin"
},
"content": "Hello everyone!",
"reply_to": null,
"timestamp": "2026-03-14T10:30:00Z",
"attachments": [],
"reactions": [],
"pinned": false
}
}
```
### chat_edit (Client -> Server)
```json
{
"type": "chat_edit",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"content": "Hello everyone! (edited)"
}
}
```
Own messages only. Max 4000 runes.
### chat_edited (Server -> Client, broadcast)
```json
{
"seq": 43,
"type": "chat_edited",
"payload": {
"message_id": 1042,
"channel_id": 5,
"content": "Hello everyone! (edited)",
"edited_at": "2026-03-14T10:31:00Z"
}
}
```
### chat_delete (Client -> Server)
```json
{
"type": "chat_delete",
"id": "req-uuid",
"payload": {
"message_id": 1042
}
}
```
Moderators with `MANAGE_MESSAGES` can delete others' messages (non-DM channels only).
### chat_deleted (Server -> Client, broadcast)
```json
{
"seq": 44,
"type": "chat_deleted",
"payload": {
"message_id": 1042,
"channel_id": 5
}
}
```
---
## Reactions
### reaction_add / reaction_remove (Client -> Server)
```json
{
"type": "reaction_add",
"payload": {
"message_id": 1042,
"emoji": "\ud83d\udc4d"
}
}
```
Rate limited at 5/sec. Requires `ADD_REACTIONS` permission (or DM participant).
### reaction_update (Server -> Client, broadcast)
```json
{
"seq": 45,
"type": "reaction_update",
"payload": {
"message_id": 1042,
"channel_id": 5,
"emoji": "\ud83d\udc4d",
"user_id": 1,
"action": "add"
}
}
```
`action` is `"add"` or `"remove"`.
---
## Typing Indicators
### typing_start (Client -> Server)
```json
{ "type": "typing_start", "payload": { "channel_id": 5 } }
```
Rate limited: 1 per 3 seconds per user per channel. Silently dropped when rate limited.
### typing (Server -> Client, broadcast)
```json
{
"type": "typing",
"payload": {
"channel_id": 5,
"user_id": 1,
"username": "alex"
}
}
```
Typing broadcasts are ephemeral -- they are NOT stored in the replay ring buffer.
---
## Presence
### presence_update (Client -> Server)
```json
{ "type": "presence_update", "payload": { "status": "online" } }
```
Valid values: `"online"`, `"idle"`, `"dnd"`, `"offline"`. Rate limited: 1 per 10 seconds.
### presence (Server -> Client, broadcast)
```json
{
"seq": 50,
"type": "presence",
"payload": {
"user_id": 1,
"status": "online"
}
}
```
---
## Channel Focus
### channel_focus (Client -> Server)
```json
{ "type": "channel_focus", "payload": { "channel_id": 5 } }
```
Tells the server which channel the user is currently viewing. Affects broadcast delivery and unread tracking.
---
## Channel Updates
All channel update messages are broadcast to all connected clients. Triggered by REST API calls from admins.
### channel_create (Server -> Client, broadcast)
```json
{
"seq": 60,
"type": "channel_create",
"payload": {
"id": 8,
"name": "gaming",
"type": "text",
"category": "Hangout",
"topic": "",
"position": 3
}
}
```
### channel_update (Server -> Client, broadcast)
Full channel object (all fields).
### channel_delete (Server -> Client, broadcast)
```json
{
"seq": 62,
"type": "channel_delete",
"payload": { "id": 8 }
}
```
---
## Member Updates
All member messages are broadcast to all connected clients.
### member_join (Server -> Client, broadcast)
Sent when a user first connects (fresh connection, not reconnect replay).
```json
{
"seq": 70,
"type": "member_join",
"payload": {
"user": {
"id": 5,
"username": "newuser",
"avatar": null,
"role": "member"
}
}
}
```
### member_update (Server -> Client, broadcast)
Triggered when an admin changes a user's role.
```json
{
"seq": 71,
"type": "member_update",
"payload": {
"user_id": 5,
"role": "moderator"
}
}
```
### member_ban (Server -> Client, broadcast)
```json
{
"seq": 72,
"type": "member_ban",
"payload": { "user_id": 5 }
}
```
---
## Voice Signaling
Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/state) while the actual audio/video flows through LiveKit's own WebSocket connection.
### voice_join (Client -> Server)
```json
{ "type": "voice_join", "payload": { "channel_id": 10 } }
```
On success, server sends (in order):
1. `voice_token` -- LiveKit JWT + URL
2. `voice_state` broadcast -- joiner's state to all clients
3. Existing `voice_state` messages -- one per existing participant (to joiner only)
4. `voice_config` -- channel audio settings (to joiner only)
### voice_token (Server -> Client, direct)
```json
{
"type": "voice_token",
"payload": {
"channel_id": 10,
"token": "eyJhbGciOiJIUzI1NiIs...",
"url": "/livekit",
"direct_url": "ws://localhost:7880"
}
}
```
### voice_config (Server -> Client, direct)
```json
{
"type": "voice_config",
"payload": {
"channel_id": 10,
"quality": "medium",
"bitrate": 64000,
"max_users": 50
}
}
```
Quality presets:
| Preset | Bitrate |
|--------|---------|
| `low` | 32,000 bps |
| `medium` | 64,000 bps |
| `high` | 128,000 bps |
### voice_leave (Client -> Server)
```json
{ "type": "voice_leave", "payload": {} }
```
### voice_leave (Server -> Client, broadcast)
```json
{
"seq": 80,
"type": "voice_leave",
"payload": {
"channel_id": 10,
"user_id": 1
}
}
```
### voice_state (Server -> Client, broadcast)
```json
{
"seq": 81,
"type": "voice_state",
"payload": {
"channel_id": 10,
"user_id": 1,
"username": "alex",
"muted": false,
"deafened": false,
"speaking": false,
"camera": false,
"screenshare": false
}
}
```
### voice_mute / voice_deafen (Client -> Server)
```json
{ "type": "voice_mute", "payload": { "muted": true } }
{ "type": "voice_deafen", "payload": { "deafened": true } }
```
### voice_camera (Client -> Server)
```json
{ "type": "voice_camera", "payload": { "enabled": true } }
```
Rate limited: 2/sec. Requires `USE_VIDEO` permission.
### voice_screenshare (Client -> Server)
```json
{ "type": "voice_screenshare", "payload": { "enabled": true } }
```
Rate limited: 2/sec. Requires `SHARE_SCREEN` permission.
### voice_token_refresh (Client -> Server)
```json
{ "type": "voice_token_refresh", "payload": {} }
```
Rate limited: 1 per 60 seconds. Must be in a voice channel.
---
## Direct Messages
### dm_channel_open (Server -> Client)
Sent when a DM is opened, created, or auto-reopened by an incoming message.
```json
{
"type": "dm_channel_open",
"payload": {
"channel_id": 100,
"recipient": {
"id": 2,
"username": "jordan",
"avatar": "uuid.png",
"status": "online"
}
}
}
```
### dm_channel_close (Server -> Client)
```json
{
"type": "dm_channel_close",
"payload": { "channel_id": 100 }
}
```
### DM Authorization
All handlers that touch a channel check the channel type and branch to participant-based authorization for DMs instead of role-based permissions. This applies to: `chat_send`, `chat_edit`, `chat_delete`, `reaction_add`/`remove`, `typing_start`, `channel_focus`.
---
## Server Restart
### server_restart (Server -> Client, broadcast)
```json
{
"seq": 100,
"type": "server_restart",
"payload": {
"reason": "update",
"delay_seconds": 5
}
}
```
---
## Error Handling
### error (Server -> Client)
```json
{
"type": "error",
"id": "original-req-uuid",
"payload": {
"code": "FORBIDDEN",
"message": "No permission to post here"
}
}
```
### Error Codes
| Code | Description |
|------|-------------|
| `BAD_REQUEST` | Invalid payload format or field values |
| `INTERNAL` | Server-side error |
| `NOT_FOUND` | Channel or message not found |
| `FORBIDDEN` | Missing required permission |
| `RATE_LIMITED` | Too many requests (includes `retry_after` in seconds) |
| `ALREADY_JOINED` | Already in this voice channel |
| `CHANNEL_FULL` | Voice channel at capacity |
| `VOICE_ERROR` | Voice-specific error |
| `VIDEO_LIMIT` | Maximum video streams reached |
| `BANNED` | User is banned |
| `INVALID_JSON` | Message is not valid JSON |
| `UNKNOWN_TYPE` | Unrecognized message type |
| `SLOW_MODE` | Channel has slow mode enabled |
| `CONFLICT` | Duplicate reaction or constraint violation |
After 10 consecutive invalid JSON messages, the connection is forcibly closed.
---
## Rate Limits
All rate limits are enforced server-side using a token bucket rate limiter.
| Action | Limit | Window | Error Response |
|--------|-------|--------|----------------|
| Chat send | 10 | 1 second | `RATE_LIMITED` error |
| Chat edit | 10 | 1 second | `RATE_LIMITED` error |
| Chat delete | 10 | 1 second | `RATE_LIMITED` error |
| Typing | 1 | 3 seconds | Silently dropped |
| Presence | 1 | 10 seconds | `RATE_LIMITED` error |
| Reactions | 5 | 1 second | `RATE_LIMITED` error |
| Voice camera | 2 | 1 second | `RATE_LIMITED` error |
| Voice screenshare | 2 | 1 second | `RATE_LIMITED` error |
| Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error |
---
## Message Type Reference Table
### Client -> Server (18 types)
| Type | Rate Limit | Notes |
|------|-----------|-------|
| `auth` | N/A (first message) | Token + optional last_seq |
| `chat_send` | 10/sec | + slow mode per channel |
| `chat_edit` | 10/sec | Own messages only |
| `chat_delete` | 10/sec | Own or mod (non-DM) |
| `reaction_add` | 5/sec | |
| `reaction_remove` | 5/sec | |
| `typing_start` | 1/3sec/channel | Silently dropped |
| `channel_focus` | None | Updates read state |
| `presence_update` | 1/10sec | |
| `voice_join` | None | |
| `voice_leave` | None | Empty payload |
| `voice_mute` | None | |
| `voice_deafen` | None | |
| `voice_camera` | 2/sec | Requires USE_VIDEO |
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
| `voice_token_refresh` | 1/60sec | Must be in voice |
| `soundboard_play` | N/A | Not yet implemented server-side |
| `ping` | None | Heartbeat |
### Server -> Client (25+ types)
| Type | Has seq? | Delivery |
|------|----------|----------|
| `auth_ok` | No | Direct |
| `auth_error` | No | Direct (then close) |
| `ready` | No | Direct |
| `chat_message` | Non-DM only | Channel or DM participants |
| `chat_send_ok` | No | Direct to sender |
| `chat_edited` | Non-DM only | Channel or DM participants |
| `chat_deleted` | Non-DM only | Channel or DM participants |
| `reaction_update` | Non-DM only | Channel or DM participants |
| `typing` | No | Channel (excl. sender) or DM |
| `presence` | Yes | All clients |
| `channel_create` | Yes | All clients |
| `channel_update` | Yes | All clients |
| `channel_delete` | Yes | All clients |
| `voice_state` | Yes | All clients |
| `voice_leave` | Yes | All clients |
| `voice_config` | No | Direct to joiner |
| `voice_token` | No | Direct to joiner |
| `member_join` | Yes | All clients |
| `member_update` | Yes | All clients |
| `member_ban` | Yes | All clients |
| `dm_channel_open` | No | Direct to participant |
| `dm_channel_close` | No | Direct to participant |
| `server_restart` | Yes | All clients |
| `error` | No | Direct to requester |
| `pong` | No | Direct to pinger |
+66
View File
@@ -0,0 +1,66 @@
# Quick Start Guide
Get OwnCord up and running in minutes.
## Prerequisites
- **Windows 10+** (x64)
- **Go 1.25+** (only if building the server from source)
- **Node.js 20+** (only if building the client from source)
- **Rust / Cargo** (only if building the Tauri client from source)
- **LiveKit Server** binary (optional, for voice/video) -- see [LiveKit Setup](livekit-setup.md)
## Step 1: Download
Get the latest release from GitHub Releases. Download `chatserver.exe` and the `OwnCord` installer.
Or build from source:
```bash
# Server
cd Server
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" .
# Client
cd Client/tauri-client
npm install
npm run tauri build
```
## Step 2: Run the Server
Run `chatserver.exe`. On first run:
1. `config.yaml` is created in the working directory with default settings
2. `data/` directory is created for the database, TLS certs, uploads, and backups
3. A self-signed TLS certificate is generated automatically
4. SQLite database is created and all migrations are applied
5. All user statuses are reset to offline (clean slate)
The server starts on `https://0.0.0.0:8443`.
See [Server Configuration](server-configuration.md) for the full config key reference and environment variable overrides.
## Step 3: Admin Setup
Open `https://localhost:8443/admin` in a browser. The first-run setup page will prompt you to create the Owner account (username + password). This user gets the Owner role with full server control.
## Step 4: Create Invites
In the admin panel, go to invite management and generate invite codes for your friends.
## Step 5: Connect Clients
Friends install OwnCord, enter your server address (IP or domain + port 8443), and redeem their invite code to register.
The client uses TOFU (Trust On First Use) for self-signed certificates -- it will prompt to trust the server's certificate on first connection, then pin it for future sessions.
## Networking
If friends are outside your local network, see the [Port Forwarding Guide](port-forwarding.md) or use [Tailscale](tailscale.md) for zero-config networking.
## Next Steps
- [Server Configuration](server-configuration.md) -- customize ports, TLS, uploads, voice
- [Deployment Guide](deployment.md) -- production hardening, backups, monitoring, Windows service setup
- [LiveKit Setup](livekit-setup.md) -- enable voice and video chat
+394
View File
@@ -0,0 +1,394 @@
# Database Schema Reference
OwnCord uses a single SQLite database file (`data/chatserver.db`) with the pure-Go driver `modernc.org/sqlite` (no CGO). Migrations run automatically on startup.
---
## Database Configuration
| PRAGMA | Value | Purpose |
|--------|-------|---------|
| `journal_mode` | `WAL` | Write-Ahead Logging for concurrent readers |
| `foreign_keys` | `ON` | Enforces all `REFERENCES` constraints |
| `busy_timeout` | `5000` | Waits up to 5 seconds for the write lock |
| `synchronous` | `NORMAL` | Safe with WAL mode, reduces fsync calls |
| `temp_store` | `MEMORY` | Temporary tables stored in RAM |
| `mmap_size` | `268435456` | 256 MB memory-mapped I/O |
| `cache_size` | `-64000` | 64 MB page cache |
SQLite only allows one writer at a time. The connection pool is pinned to a single connection.
---
## Migration System
Migrations are embedded `.sql` files applied in lexicographic order. Each migration runs in a transaction and is tracked in `schema_versions`.
```sql
CREATE TABLE IF NOT EXISTS schema_versions (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### Migration History
| File | Description |
|------|-------------|
| `001_initial_schema.sql` | All core tables, default roles and settings |
| `002_voice_states.sql` | Adds `voice_states` table |
| `003_audit_log.sql` | Recreates `audit_log` with renamed columns |
| `003_voice_optimization.sql` | Adds `camera`, `screenshare` to voice_states; voice settings to channels |
| `004_fix_member_permissions.sql` | Fixes Member role permissions |
| `005_channel_overrides_index.sql` | Adds composite index on channel_overrides |
| `006_member_video_permissions.sql` | Adds USE_VIDEO and SHARE_SCREEN to Member role |
| `007_attachment_dimensions.sql` | Adds `width` and `height` to attachments |
| `008_dm_tables.sql` | Adds `dm_participants` and `dm_open_state` tables |
---
## Tables
### roles
Defines permission tiers.
```sql
CREATE TABLE roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT,
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0
);
```
**Default roles:**
| id | name | color | permissions | position | Notes |
|----|------|-------|-------------|----------|-------|
| 1 | Owner | `#E74C3C` | `0x7FFFFFFF` | 100 | All 31 permission bits set |
| 2 | Admin | `#F39C12` | `0x3FFFFFFF` | 80 | Everything except ADMINISTRATOR |
| 3 | Moderator | `#3498DB` | `0x000FFFFF` | 60 | All message + voice + moderation |
| 4 | Member | NULL | `0x1E63` | 40 | Send, read, attach, react, voice, video, screen share |
---
### users
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
avatar TEXT,
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
totp_secret TEXT,
status TEXT NOT NULL DEFAULT 'offline',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT
);
```
Valid status values: `online`, `idle`, `dnd`, `offline`. All statuses are reset to `offline` on server startup.
---
### sessions
```sql
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
device TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
```
Session TTL: 30 days. Token is stored as SHA-256 hash.
---
### channels
```sql
CREATE TABLE channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
category TEXT,
topic TEXT,
position INTEGER NOT NULL DEFAULT 0,
slow_mode INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0,
voice_quality TEXT,
mixing_threshold INTEGER,
voice_max_video INTEGER NOT NULL DEFAULT 25
);
```
Channel types: `text`, `voice`, `announcement`, `dm`.
---
### channel_overrides
Per-channel permission overrides for specific roles.
```sql
CREATE TABLE channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
UNIQUE(channel_id, role_id)
);
```
Effective permission calculation: `effective = (base_permissions & ~deny) | allow`
---
### messages
```sql
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
```
Messages are soft-deleted (`deleted = 1`), never physically removed by user action.
---
### messages_fts (FTS5 Virtual Table)
Full-text search index synchronized via triggers.
```sql
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content='messages',
content_rowid='id'
);
```
Supports FTS5 query syntax: simple terms, phrase queries, prefix queries, boolean operators (`AND`, `OR`, `NOT`).
---
### attachments
```sql
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_as TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
width INTEGER,
height INTEGER
);
```
Uses UUID primary keys. `message_id` is NULL during upload, linked when the message is sent.
---
### reactions
```sql
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(message_id, user_id, emoji)
);
```
---
### invites
```sql
CREATE TABLE invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
redeemed_by INTEGER REFERENCES users(id),
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0
);
```
Invite codes are 8 random bytes encoded as hex. Uses are validated and incremented atomically.
---
### read_states
```sql
CREATE TABLE read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0,
mention_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
```
---
### audit_log
```sql
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL DEFAULT 0,
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
### voice_states
Ephemeral -- all rows deleted on server startup.
```sql
CREATE TABLE voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
camera INTEGER NOT NULL DEFAULT 0,
screenshare INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
### dm_participants
```sql
CREATE TABLE dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
```
---
### dm_open_state
```sql
CREATE TABLE dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
```
---
## Indexes
| Index Name | Table | Columns | Purpose |
|------------|-------|---------|---------|
| `idx_sessions_token` | sessions | `(token)` | Fast session lookup by token hash |
| `idx_sessions_user` | sessions | `(user_id)` | Fast deletion of all sessions for a user |
| `idx_messages_channel` | messages | `(channel_id, id DESC)` | Latest messages in channel query |
| `idx_messages_user` | messages | `(user_id)` | Filter by author |
| `idx_invites_code` | invites | `(code)` | Fast invite validation |
| `idx_audit_timestamp` | audit_log | `(created_at DESC)` | Pagination of audit log |
| `idx_audit_log_actor` | audit_log | `(actor_id)` | Filter by actor |
| `idx_login_ip` | login_attempts | `(ip_address, timestamp)` | Rate limiting queries |
| `idx_voice_states_channel` | voice_states | `(channel_id)` | All users in a voice channel |
| `idx_channel_overrides_channel_role` | channel_overrides | `(channel_id, role_id)` | Permission lookup |
| `idx_dm_participants_user` | dm_participants | `(user_id)` | DM channel lookup |
---
## Permission Bitfield System
Permissions are stored as an integer bitfield (31 bits used) in `roles.permissions`, `channel_overrides.allow`, and `channel_overrides.deny`.
### Bit Map
| Bit | Hex | Name | Description |
|-----|-----|------|-------------|
| 0 | `0x1` | `SEND_MESSAGES` | Post messages in text channels |
| 1 | `0x2` | `READ_MESSAGES` | View messages in text channels |
| 5 | `0x20` | `ATTACH_FILES` | Upload file attachments |
| 6 | `0x40` | `ADD_REACTIONS` | Add emoji reactions |
| 8 | `0x100` | `USE_SOUNDBOARD` | Play sounds in voice channels |
| 9 | `0x200` | `CONNECT_VOICE` | Join voice channels |
| 10 | `0x400` | `SPEAK_VOICE` | Transmit audio in voice channels |
| 11 | `0x800` | `USE_VIDEO` | Enable camera in voice channels |
| 12 | `0x1000` | `SHARE_SCREEN` | Share screen in voice channels |
| 16 | `0x10000` | `MANAGE_MESSAGES` | Delete others' messages, pin/unpin |
| 17 | `0x20000` | `MANAGE_CHANNELS` | Create, edit, delete channels |
| 18 | `0x40000` | `KICK_MEMBERS` | Kick users |
| 19 | `0x80000` | `BAN_MEMBERS` | Ban/unban users |
| 20 | `0x100000` | `MUTE_MEMBERS` | Server-side mute/deafen in voice |
| 24 | `0x1000000` | `MANAGE_ROLES` | Create, edit, delete roles |
| 25 | `0x2000000` | `MANAGE_SERVER` | Modify server settings |
| 26 | `0x4000000` | `MANAGE_INVITES` | Create and revoke invite codes |
| 27 | `0x8000000` | `VIEW_AUDIT_LOG` | View the audit log |
| 30 | `0x40000000` | `ADMINISTRATOR` | Bypasses ALL permission checks |
Bits 2-4, 7, 13-15, 21-23, 28-29, 31 are reserved.
### Permission Checking Logic
```
1. Get the user's role -> role.Permissions (base)
2. If (base & ADMINISTRATOR) != 0 -> ALLOW everything
3. Get channel_overrides for (channel_id, role_id) -> allow, deny
4. effective = (base | allow) & ~deny
5. Check: (effective & required_permission) != 0
```
DM channels bypass role permissions entirely and use participant-based authorization instead.
### Default Role Permission Values
| Role | Hex | Permissions |
|------|-----|-------------|
| Owner | `0x7FFFFFFF` | Everything including ADMINISTRATOR |
| Admin | `0x3FFFFFFF` | Everything except ADMINISTRATOR |
| Moderator | `0x000FFFFF` | All message + voice + moderation |
| Member | `0x1E63` | Send, read, attach, react, voice, video, screen share |
+41
View File
@@ -0,0 +1,41 @@
# Security Policy
Security guidelines and vulnerability reporting for OwnCord.
## Reporting Vulnerabilities
Use GitHub Security Advisories to report vulnerabilities: go to Settings > Security > Advisories and create a new advisory.
**Do NOT open public issues for security bugs.**
## Response Timeline
- **Acknowledgment:** Within 48 hours
- **Critical fixes:** Within 7 days
- **Non-critical fixes:** Included in the next release
## Two-Factor Authentication
OwnCord supports TOTP-based 2FA:
- Users enroll via Settings > Account (QR code + backup codes)
- Admins can enforce server-wide 2FA via the `require_2fa` setting in the admin panel
- `require_2fa` requires all users to have 2FA enabled and registration to be closed
- Login flow returns `requires_2fa: true` with a `partial_token` (10-min TTL, 5-attempt limit)
- Auth challenges are rate-limited to 10 req/min per IP
## Known Limitations
- No code signing yet -- binaries are verified via SHA256 checksums only
## Security Hardening Checklist for Operators
- [ ] Enable TLS (self-signed is the default; custom certs recommended for production)
- [ ] Keep invite-only registration enabled (default)
- [ ] Set a strong admin password
- [ ] Configure rate limits (defaults are sensible but review for your use case)
- [ ] Run regular backups via the admin panel
- [ ] Keep the server updated (admin panel shows available updates)
- [ ] Firewall: only expose port 8443 (HTTPS) and 7880 (LiveKit WebSocket for voice/video)
- [ ] Enable server-wide 2FA requirement once all users have enrolled
- [ ] Set `admin_allowed_cidrs` to restrict admin panel access to trusted networks
+141
View File
@@ -0,0 +1,141 @@
# Server Configuration Reference
Complete reference for all OwnCord server configuration options.
## Overview
OwnCord server reads configuration from `config.yaml` in the working directory. On first run, if the file does not exist, a default `config.yaml` is created automatically.
Configuration is loaded in three layers (later layers override earlier ones):
1. **Built-in defaults** (compiled into the binary)
2. **YAML file** (`config.yaml`)
3. **Environment variables** (prefix: `OWNCORD_`)
## Config Key Reference
### Server (`server`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `server.port` | int | `8443` | HTTP(S) listen port |
| `server.name` | string | `"OwnCord Server"` | Server display name (shown in `/api/v1/info` and admin panel) |
| `server.data_dir` | string | `"data"` | Directory for database, certs, uploads, backups |
| `server.allowed_origins` | string[] | `["*"]` | WebSocket CORS allowed origins; restrict in production |
| `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) |
| `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` |
### TLS (`tls`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `tls.mode` | string | `"self_signed"` | TLS mode: `self_signed`, `acme`, `manual`, `off` |
| `tls.cert_file` | string | `"data/cert.pem"` | Path to TLS certificate (used by `manual` and `self_signed`) |
| `tls.key_file` | string | `"data/key.pem"` | Path to TLS private key |
| `tls.domain` | string | `""` | Domain for ACME/Let's Encrypt (required when `mode: acme`) |
| `tls.acme_cache_dir` | string | `"data/acme_certs"` | Directory for cached Let's Encrypt certificates |
### Database (`database`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `database.path` | string | `"data/chatserver.db"` | Path to SQLite database file |
### Uploads (`upload`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `upload.max_size_mb` | int | `100` | Maximum file upload size in megabytes |
| `upload.storage_dir` | string | `"data/uploads"` | Directory where uploaded files are stored |
### Voice / LiveKit (`voice`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `voice.livekit_api_key` | string | *(random per run)* | LiveKit API key. Set a stable value for persistent voice tokens. |
| `voice.livekit_api_secret` | string | *(random per run)* | LiveKit API secret (min 32 chars). Set a stable value for persistent tokens. |
| `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL |
| `voice.livekit_binary` | string | `""` | Path to `livekit-server` binary; empty = don't auto-start |
| `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. |
| `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` |
> **Warning:** If `livekit_api_key` or `livekit_api_secret` are left empty, random credentials are generated on each startup. This means voice tokens break on restart. Always set stable credentials in production. See [LiveKit Setup](livekit-setup.md) for details.
### GitHub / Updates (`github`)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `github.token` | string | `""` | Optional GitHub API token for higher rate limits on update checks (5000 req/hr vs 60) |
## Environment Variable Overrides
Every config key can be overridden via environment variables using the prefix `OWNCORD_`.
**Format:** `OWNCORD_<SECTION>_<KEY>`
| Environment Variable | Config Path |
|---------------------|-------------|
| `OWNCORD_SERVER_PORT` | `server.port` |
| `OWNCORD_SERVER_NAME` | `server.name` |
| `OWNCORD_SERVER_DATA_DIR` | `server.data_dir` |
| `OWNCORD_DATABASE_PATH` | `database.path` |
| `OWNCORD_TLS_MODE` | `tls.mode` |
| `OWNCORD_TLS_CERT_FILE` | `tls.cert_file` |
| `OWNCORD_TLS_DOMAIN` | `tls.domain` |
| `OWNCORD_UPLOAD_MAX_SIZE_MB` | `upload.max_size_mb` |
| `OWNCORD_UPLOAD_STORAGE_DIR` | `upload.storage_dir` |
| `OWNCORD_VOICE_LIVEKIT_API_KEY` | `voice.livekit_api_key` |
| `OWNCORD_VOICE_LIVEKIT_API_SECRET` | `voice.livekit_api_secret` |
| `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` |
| `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` |
| `OWNCORD_VOICE_QUALITY` | `voice.quality` |
| `OWNCORD_GITHUB_TOKEN` | `github.token` |
## Example config.yaml
```yaml
# OwnCord Server Configuration
server:
port: 8443
name: "OwnCord Server"
data_dir: "data"
allowed_origins: ["*"] # restrict in production
trusted_proxies: [] # e.g. ["10.0.0.0/8"] if behind a reverse proxy
admin_allowed_cidrs:
- "127.0.0.0/8"
- "::1/128"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
database:
path: "data/chatserver.db"
tls:
mode: "self_signed" # self_signed | acme | manual | off
cert_file: "data/cert.pem"
key_file: "data/key.pem"
domain: "" # required for acme mode
acme_cache_dir: "data/acme_certs"
upload:
max_size_mb: 100
storage_dir: "data/uploads"
voice:
livekit_api_key: "your-api-key"
livekit_api_secret: "your-secret-at-least-32-characters-long"
livekit_url: "ws://localhost:7880"
livekit_binary: "" # path to livekit-server binary
node_ip: "" # public IP for remote users behind NAT
quality: "medium" # low | medium | high
github:
token: "" # optional GitHub PAT for update check rate limits
```
## See Also
- [Deployment Guide](deployment.md) -- production deployment guide
- [LiveKit Setup](livekit-setup.md) -- voice/video setup
- [Quick Start](quick-start.md) -- getting started
+23
View File
@@ -0,0 +1,23 @@
# Tailscale Guide (Zero-Config Alternative)
Use Tailscale for secure, zero-config networking without port forwarding.
## What is Tailscale
Tailscale is a mesh VPN that creates encrypted tunnels between your devices using WireGuard. No port forwarding, no dynamic DNS, and it works behind CGNAT. Free for personal use.
## Setup
1. **Install Tailscale** on the server machine and each client machine: https://tailscale.com/download
2. **Sign in** with the same Tailscale account (or share the machine using Tailscale's sharing feature)
3. **Find the server's Tailscale IP** -- shown in the Tailscale app, typically `100.x.y.z`
4. **Disable TLS in config** -- set `tls.mode` to `"off"` in `config.yaml` since Tailscale already encrypts all traffic with WireGuard
5. **Connect clients** using the Tailscale IP: `100.x.y.z:8443`
## Benefits
- No port forwarding needed
- Works behind CGNAT and strict firewalls
- Encrypted by default (WireGuard)
- Stable IPs that don't change
- Easy to add/remove friends via the Tailscale admin console