fix: Fix containing various changes that stacked up in a period of time (#22)
This commit is contained in:
1 parent
1b3d7cecb2
commit
77afcca98e
163 files changed
+4268
-3852
No files matched your search
@@ -13,9 +13,11 @@
|
||||
"clean": "rm -rf dist .turbo node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/kits": "workspace:*",
|
||||
"@ermnvldmr/ssg": "workspace:*",
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@ermnvldmr/ui": "workspace:*",
|
||||
"lucide-react": "^0.503.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Container, HStack, ThemeSwitch, VStack, Text, Separator, PageRoot } from '@ermnvldmr/ui';
|
||||
import { PageRoot, ThemeSwitch } from '@ermnvldmr/kits';
|
||||
import { Container, HStack, Icon, Separator, Text, VStack } from '@ermnvldmr/ui';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
@@ -34,7 +36,11 @@ export function IndexLayout({ children }: IndexLayoutProps): React.JSX.Element {
|
||||
© {currentYear} Vladimir Eremin
|
||||
</Text>
|
||||
</VStack>
|
||||
<ThemeSwitch />
|
||||
<ThemeSwitch
|
||||
darkIcon={<Icon icon={Moon} />}
|
||||
lightIcon={<Icon icon={Sun} />}
|
||||
systemIcon={<Icon icon={Monitor} />}
|
||||
/>
|
||||
</HStack>
|
||||
</Container>
|
||||
</VStack>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import '../../../../packages/ui/src/static/styles.css';
|
||||
@source '../../../../packages/ui/src/components';
|
||||
@source '../../../../packages/ui/src/lib';
|
||||
@source '../../../../packages/kits/src';
|
||||
@@ -4,6 +4,10 @@
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"#*": ["./*"],
|
||||
"@ermnvldmr/cli": ["../../../packages/cli/src"],
|
||||
"@ermnvldmr/cli/*": ["../../../packages/cli/src/*"],
|
||||
"@ermnvldmr/kits": ["../../../packages/kits/src"],
|
||||
"@ermnvldmr/kits/*": ["../../../packages/kits/src/*"],
|
||||
"@ermnvldmr/ssg": ["../../../packages/ssg/src"],
|
||||
"@ermnvldmr/ssg/*": ["../../../packages/ssg/src/*"],
|
||||
"@ermnvldmr/stl": ["../../../packages/stl/src"],
|
||||
@@ -11,6 +15,6 @@
|
||||
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", ".config/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.config.*"]
|
||||
}
|
||||
@@ -1 +1,49 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserverMock;
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// jsdom sometimes does not expose localStorage.clear(); provide a functional polyfill
|
||||
if (typeof window.localStorage.clear !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string): string | null => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string): void => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: (): void => {
|
||||
store.clear();
|
||||
},
|
||||
key: (index: number): string | null => [...store.keys()][index] ?? null,
|
||||
get length(): number {
|
||||
return store.size;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 'Joplin Web Deployment Guide'
|
||||
description: 'Joplin Server & Web App deployment from scratch'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['joplin', 'deployment', 'guide']
|
||||
tags: ['self-hosting', 'guides', 'joplin']
|
||||
---
|
||||
|
||||
This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
title: 'Руководство по развертыванию Joplin Web'
|
||||
description: 'Развертывание Joplin Server и веб-приложения с нуля'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['joplin', 'deployment', 'guide']
|
||||
tags: ['self-hosting', 'guides', 'joplin']
|
||||
---
|
||||
|
||||
Это руководство описывает, как развернуть приватную инфраструктуру для заметок, используя Joplin Server и его веб-клиент.
|
||||
|
||||
@@ -1,280 +1,435 @@
|
||||
---
|
||||
title: 'Matrix Server Deployment Guide'
|
||||
description: 'Synapse & LiveKit servers deployment from scratch'
|
||||
description: 'Understanding and deploying Synapse, Coturn, LiveKit SFU, and Native Matrix RTC'
|
||||
createdDate: 2026-02-15
|
||||
tags: ['matrix', 'deployment', 'guide']
|
||||
tags: ['self-hosting', 'guides', 'matrix']
|
||||
---
|
||||
|
||||
This guide is for **educational purposes** only. It describes how to deploy a private communications infrastructure to understand the decentralized Matrix protocol and the technical implementation of media relays.
|
||||
Deploying a Matrix communications stack involves more than running a single web server. Matrix is an open, federated protocol for real-time, decentralized, end-to-end encrypted communication. While basic messaging requires only a homeserver and a database, modern voice and video capabilities—ranging from peer-to-peer 1:1 calls to multi-party group video conferences—require dedicated media relays and authentication bridges.
|
||||
|
||||
## Why Self-Host?
|
||||
|
||||
Centralized platforms often compromise data for analytics or are subject to external policy shifts. By hosting your own Matrix instance, you achieve:
|
||||
|
||||
- **Data Sovereignty**: You own the hardware and the database.
|
||||
- **End-to-End Encryption (E2EE)**: Messages are encrypted locally on devices; the server only relays ciphertext.
|
||||
- **Infrastructure Resilience**: Your communication is independent of large provider outages.
|
||||
- **Metadata Privacy**: You control logs regarding who communicates with whom.
|
||||
This article walks through the inner workings and deployment of a production-ready Matrix stack utilizing **Synapse**, **PostgreSQL**, **Coturn**, **LiveKit SFU**, and **lk-jwt-service**.
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack Overview
|
||||
## Technical Stack Architecture
|
||||
|
||||
1. **Synapse**: The "Homeserver." It handles the Matrix protocol, account logic, and message synchronization.
|
||||
2. **Postgres**: The database. Essential for production performance (SQLite is for testing only).
|
||||
3. **Coturn (STUN/TURN)**: When two people call each other, firewalls often block direct connections. Coturn acts as a relay to bypass these NAT restrictions.
|
||||
4. **LiveKit**: A modern media engine (SFU). While Coturn is for 1:1 calls, LiveKit handles group calls by multiplexing video/audio streams efficiently.
|
||||
5. **lk-jwt-service**: An authentication bridge. This service checks if a user is logged into your Synapse server and issues a temporary token (JWT) to allow them into the call.
|
||||
To understand how a Matrix communications deployment functions, we must look at how each of its five core components handles data:
|
||||
|
||||
1. **Synapse**: The primary homeserver engine. It manages user authentication, room state, message synchronization, end-to-end encryption (E2EE) key distribution, and federation with other homeservers across the global Matrix network.
|
||||
2. **PostgreSQL**: The relational database powering Synapse. While Synapse supports SQLite for quick local experimentation, PostgreSQL is mandatory for production to handle concurrent database transactions, index room state, and maintain message history efficiently.
|
||||
3. **Coturn (STUN/TURN)**: The NAT traversal relay. When two users initiate a 1:1 call, their devices attempt a direct peer-to-peer WebRTC connection. If firewalls or symmetric NATs block direct traffic, Coturn acts as a relay server, forwarding encrypted audio and video packets between peers over UDP or TCP.
|
||||
4. **LiveKit SFU**: A high-performance Selective Forwarding Unit. Peer-to-peer meshes become bandwidth-prohibitive in group calls with three or more participants. LiveKit receives media streams from each speaker and forwards them to all other room members, reducing client upstream bandwidth.
|
||||
5. **lk-jwt-service**: The authentication bridge for Element Call and Element X Native Matrix RTC (governed by MSC4143 and MSC4186). When a user joins a group call in Element X, `lk-jwt-service` verifies their Matrix session with Synapse and issues a short-lived JSON Web Token (JWT), allowing them to authenticate with LiveKit automatically.
|
||||
6. **Reverse Proxy (Traefik / Nginx)**: The edge gateway. It terminates TLS certificates, handles WebSocket protocol upgrades for LiveKit signaling (`/livekit`), injects CORS headers for web clients, and serves Matrix client and federation auto-discovery JSON payloads (`.well-known`).
|
||||
|
||||
---
|
||||
|
||||
## 1. The Deployment Stack
|
||||
## 1. The Docker Infrastructure (docker-compose.yml)
|
||||
|
||||
This configuration is generic, uses **Docker Volumes**, and is set for **UID 1000:1000** for standard hardening.
|
||||
Organizing these services into containerized units ensures isolated environments, consistent startup ordering, and clean network mapping.
|
||||
|
||||
In our setup, PostgreSQL includes a healthcheck (`pg_isready`) so that Synapse waits until database initialization is complete before attempting schema migrations. Services like Coturn, LiveKit, and `lk-jwt-service` run in network namespace mode (`network_mode: "service:synapse"`) or dedicated container networks, simplifying loopback communication while binding necessary host WebRTC and STUN/TURN ports directly.
|
||||
|
||||
Notice that specific port ranges are opened for media traffic:
|
||||
- **Port 3478 & 5349 (TCP/UDP)**: Coturn STUN and TURN signaling.
|
||||
- **Port Range 49152-49172 (UDP)**: Coturn media relay channels.
|
||||
- **Port 7880 (TCP)**: LiveKit HTTP signaling and WebSockets.
|
||||
- **Port 7881 (TCP)**: LiveKit WebRTC TCP fallback.
|
||||
- **Port Range 50000-50020 (UDP)**: LiveKit SFU media channels.
|
||||
|
||||
The configuration below finalizes the infrastructure definition:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
proxy:
|
||||
name: matrix-proxy
|
||||
internal:
|
||||
internal: true
|
||||
matrix:
|
||||
name: matrix-network
|
||||
|
||||
volumes:
|
||||
synapse_data:
|
||||
postgres_data:
|
||||
traefik_certs:
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: matrix-proxy
|
||||
command:
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.web.http.redirections.entrypoint.to=websecure'
|
||||
- '--certificatesresolvers.myresolver.acme.dnschallenge.provider=cloudflare'
|
||||
- '--certificatesresolvers.myresolver.acme.email=${ADMIN_EMAIL}'
|
||||
- '--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json'
|
||||
environment:
|
||||
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- traefik_certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.146.0
|
||||
container_name: matrix-synapse
|
||||
user: '1000:1000'
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
volumes:
|
||||
- synapse_data:/data
|
||||
environment:
|
||||
SYNAPSE_SERVER_NAME: '${DOMAIN}'
|
||||
SYNAPSE_REPORT_STATS: 'no'
|
||||
SYNAPSE_CONFIG_PATH: '/data/homeserver.yaml'
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.synapse.rule=Host(`${DOMAIN}`)'
|
||||
- 'traefik.http.services.synapse.loadbalancer.server.port=8008'
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: matrix-db
|
||||
user: '1000:1000'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: synapse
|
||||
POSTGRES_USER: synapse
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C'
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U synapse']
|
||||
interval: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
container_name: matrix-livekit
|
||||
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||
environment:
|
||||
LIVEKIT_KEYS: '${LK_KEY}: ${LK_SECRET}'
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7881:7881'
|
||||
- '50000-50020:50000-50020/udp'
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit`)'
|
||||
- 'traefik.http.middlewares.lk-strip.stripprefix.prefixes=/livekit'
|
||||
- 'traefik.http.routers.lk.middlewares=lk-strip'
|
||||
- matrix
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U synapse -d synapse']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
lk-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.1
|
||||
container_name: matrix-lk-jwt
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.146.0
|
||||
container_name: matrix-synapse
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LIVEKIT_URL: wss://${DOMAIN}/livekit
|
||||
LIVEKIT_KEY: '${LK_KEY}'
|
||||
LIVEKIT_SECRET: '${LK_SECRET}'
|
||||
SYNAPSE_SERVER_NAME: '${DOMAIN}'
|
||||
SYNAPSE_REPORT_STATS: 'no'
|
||||
SYNAPSE_CONFIG_PATH: '/data/homeserver.yaml'
|
||||
volumes:
|
||||
- ./homeserver.yaml:/data/homeserver.yaml:ro
|
||||
- synapse_data:/data
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)'
|
||||
- 'traefik.http.middlewares.lk-jwt-strip.stripprefix.prefixes=/livekit/jwt'
|
||||
- 'traefik.http.routers.lk-jwt.middlewares=lk-jwt-strip'
|
||||
- matrix
|
||||
ports:
|
||||
# Matrix Client & Federation HTTP API
|
||||
- '8008:8008'
|
||||
# Coturn STUN/TURN Signaling
|
||||
- '3478:3478/tcp'
|
||||
- '3478:3478/udp'
|
||||
- '5349:5349/tcp'
|
||||
- '5349:5349/udp'
|
||||
# Coturn Media Relay UDP Range
|
||||
- '49152-49172:49152-49172/udp'
|
||||
# LiveKit Signaling & WebRTC Ports
|
||||
- '7880:7880/tcp'
|
||||
- '7881:7881/tcp'
|
||||
- '50000-50020:50000-50020/udp'
|
||||
restart: unless-stopped
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:4.8-alpine
|
||||
container_name: matrix-coturn
|
||||
user: '1000:1000'
|
||||
ports:
|
||||
- '3478:3478'
|
||||
- '3478:3478/udp'
|
||||
- '5349:5349'
|
||||
- '5349:5349/udp'
|
||||
- '49152-49172:49152-49172/udp'
|
||||
depends_on:
|
||||
- synapse
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
network_mode: 'service:synapse'
|
||||
command: ['-c', '/etc/coturn/turnserver.conf']
|
||||
restart: unless-stopped
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
container_name: matrix-livekit
|
||||
depends_on:
|
||||
- synapse
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: '${LK_KEY}: ${LK_SECRET}'
|
||||
network_mode: 'service:synapse'
|
||||
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||
restart: unless-stopped
|
||||
|
||||
lk-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.1
|
||||
container_name: matrix-lk-jwt
|
||||
depends_on:
|
||||
- synapse
|
||||
environment:
|
||||
LK_JWT_PORT: 8080
|
||||
LIVEKIT_URL: wss://${DOMAIN}/livekit
|
||||
LIVEKIT_KEY: '${LK_KEY}'
|
||||
LIVEKIT_SECRET: '${LK_SECRET}'
|
||||
network_mode: 'service:synapse'
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Configuration
|
||||
## 2. Homeserver Configuration (homeserver.yaml)
|
||||
|
||||
Create a `.env` file in the same directory. **Do not skip the secret generation step below.**
|
||||
Synapse reads its operational rules from `homeserver.yaml`. Understanding key sections of this file prevents common administrative issues:
|
||||
|
||||
```bash
|
||||
# --- Infrastructure ---
|
||||
DOMAIN=matrix.example.com
|
||||
SERVER_IP=1.2.3.4
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
- **Server Identity (`server_name`)**: This string defines your server's authority and user ID domain suffix (e.g., `@user:matrix.example.com`). Changing this later breaks existing user IDs and room history.
|
||||
- **Database Connection (`database`)**: Connects Synapse to PostgreSQL using the `psycopg2` driver. Setting `cp_min` and `cp_max` manages connection pooling efficiently under load.
|
||||
- **Media Retention (`media_retention`)**: Matrix room members frequently share images and files. Setting `remote_media_lifetime` to 14 days automatically purges cached media from federated servers, protecting your disk space while preserving local media (`local_media_lifetime: 365d`).
|
||||
- **VoIP Integration (`turn_uris` & `turn_shared_secret`)**: Informs Synapse of your Coturn installation. When two users place a call, Synapse uses `turn_shared_secret` to generate ephemeral, time-limited credentials, allowing clients to authenticate with Coturn securely.
|
||||
- **Native Matrix RTC (MSC4143 / MSC4186)**: Modern clients like Element X and Element Call use Matrix Specification Proposals MSC4143 (RTC Foci) and MSC4186 (LiveKit Call Authentication). Adding `extra_well_known_client_content` tells Matrix clients that group call authentication is handled by your `lk-jwt-service` endpoint (`https://matrix.example.com/livekit/jwt`).
|
||||
|
||||
# --- Cloudflare (for Traefik SSL) ---
|
||||
CF_DNS_API_TOKEN=your_cloudflare_api_token_here
|
||||
The complete `homeserver.yaml` configuration is structured as follows:
|
||||
|
||||
# --- Database ---
|
||||
DB_PASSWORD=generate_this_with_openssl
|
||||
```yaml
|
||||
# Server Identity
|
||||
server_name: "matrix.example.com"
|
||||
pid_file: /data/homeserver.pid
|
||||
public_baseurl: "https://matrix.example.com"
|
||||
|
||||
# --- LiveKit Secrets ---
|
||||
# API Key can be any alphanumeric string
|
||||
LK_KEY=matrix_lk_key
|
||||
# Secret MUST be generated
|
||||
LK_SECRET=generate_this_with_openssl
|
||||
# Listeners
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
resources:
|
||||
- names: [client, federation]
|
||||
compress: false
|
||||
|
||||
# --- Coturn / Synapse VoIP ---
|
||||
# Used for STUN/TURN authentication
|
||||
TURN_SECRET=generate_this_with_openssl
|
||||
# Database
|
||||
database:
|
||||
name: psycopg2
|
||||
args:
|
||||
user: "synapse"
|
||||
password: "YOUR_POSTGRES_PASSWORD"
|
||||
database: "synapse"
|
||||
host: "postgres"
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
|
||||
```
|
||||
# Security & Registration
|
||||
enable_registration: false
|
||||
enable_registration_captcha: false
|
||||
registration_shared_secret: "YOUR_REGISTRATION_SHARED_SECRET"
|
||||
|
||||
### Generating Secrets
|
||||
# Media Storage & Retention
|
||||
media_store_path: "/data/media_store"
|
||||
max_upload_size: "50M"
|
||||
max_image_pixels: "32M"
|
||||
dynamic_thumbnails: true
|
||||
|
||||
Use `openssl` to generate secure, random strings for your `.env` entries:
|
||||
media_retention:
|
||||
remote_media_lifetime: 14d
|
||||
local_media_lifetime: 365d
|
||||
|
||||
```bash
|
||||
# Generate DB_PASSWORD, LK_SECRET, and TURN_SECRET
|
||||
openssl rand -hex 32
|
||||
caches:
|
||||
global_factor: 0.5
|
||||
|
||||
# VoIP / Coturn (1:1 Calls)
|
||||
turn_uris:
|
||||
- "turn:matrix.example.com:3478?transport=udp"
|
||||
- "turn:matrix.example.com:3478?transport=tcp"
|
||||
turn_shared_secret: "YOUR_TURN_SHARED_SECRET"
|
||||
turn_user_lifetime: 1h
|
||||
turn_allow_guests: true
|
||||
|
||||
# Native Matrix RTC (MSC4143 / MSC4186 for Element X Group Calls)
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
|
||||
serve_server_wellknown: true
|
||||
extra_well_known_client_content:
|
||||
"org.matrix.msc4186.stable": {"enabled": true}
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
|
||||
report_stats: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Nginx Counterpart & Certificates
|
||||
## 3. LiveKit SFU Configuration (livekit.yaml)
|
||||
|
||||
If you don't use Traefik, obtain certificates via **Certbot**:
|
||||
LiveKit powers group calls by receiving publisher streams and distributing them to subscribers. Key directives in `livekit.yaml` control media routing:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d matrix.example.com
|
||||
- **`bind_addresses` & `port`**: Sets LiveKit to listen for HTTP signaling and WebSocket connections on port `7880`.
|
||||
- **`rtc.use_external_ip: true`**: Crucial for cloud server environments behind NAT. Instructs LiveKit to discover and advertise the host node's public IP address (`--node-ip`) to connecting WebRTC clients.
|
||||
- **`rtc.port_range_start` & `rtc.port_range_end`**: Restricts dynamic UDP WebRTC media allocation to ports `50000-50020`, keeping firewall policy simple.
|
||||
- **`room.auto_create: true`**: Allows rooms to be dynamically allocated whenever `lk-jwt-service` issues a valid access token.
|
||||
|
||||
Here is the resulting `livekit.yaml`:
|
||||
|
||||
```yaml
|
||||
port: 7880
|
||||
bind_addresses:
|
||||
- 0.0.0.0
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 50020
|
||||
use_external_ip: true
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
|
||||
logging:
|
||||
level: info
|
||||
```
|
||||
|
||||
### Nginx Config
|
||||
---
|
||||
|
||||
## 4. Coturn Relay Configuration (turnserver.conf)
|
||||
|
||||
Coturn handles fallback media relaying for 1:1 calls. When configuring `turnserver.conf`, several settings ensure reliability and security:
|
||||
|
||||
- **`listening-port=3478`**: Standard port for STUN/TURN requests over UDP and TCP.
|
||||
- **`external-ip`**: Tells Coturn its public IP address so it can communicate valid relay candidates to remote peers.
|
||||
- **`min-port` & `max-port`**: Limits dynamic UDP relay allocation to `49152-49172`.
|
||||
- **`use-auth-secret` & `static-auth-secret`**: Enables secret-based authentication matching `turn_shared_secret` in `homeserver.yaml`.
|
||||
- **`realm`**: The domain name associated with your TURN server, required for HMAC token verification.
|
||||
- **`no-multicast-peers`**: Prevents malicious users from abusing the TURN relay to probe internal private network addresses.
|
||||
|
||||
Below is the complete `turnserver.conf` file:
|
||||
|
||||
```conf
|
||||
listening-port=3478
|
||||
listening-ip=0.0.0.0
|
||||
|
||||
# Public IP address of your server
|
||||
external-ip=203.0.113.10
|
||||
|
||||
min-port=49152
|
||||
max-port=49172
|
||||
|
||||
fingerprint
|
||||
use-auth-secret
|
||||
static-auth-secret=YOUR_TURN_SHARED_SECRET
|
||||
realm=matrix.example.com
|
||||
|
||||
total-quota=100
|
||||
stale-nonce=600
|
||||
log-file=stdout
|
||||
simple-log
|
||||
no-multicast-peers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment Setup (.env)
|
||||
|
||||
Storing credentials in environment variables keeps secret values out of configuration files and version control.
|
||||
|
||||
Secrets such as `DB_PASSWORD`, `LK_SECRET`, and `TURN_SECRET` should be cryptographically random strings. We use `openssl` to generate secure 64-character hex strings.
|
||||
|
||||
Here is the `.env` template file:
|
||||
|
||||
```bash
|
||||
# Infrastructure
|
||||
DOMAIN=matrix.example.com
|
||||
SERVER_IP=203.0.113.10
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Database
|
||||
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
|
||||
|
||||
# LiveKit (Group Calls)
|
||||
LK_KEY=matrix_lk_key
|
||||
LK_SECRET=YOUR_LIVEKIT_SECRET
|
||||
|
||||
# Coturn (1:1 Calls)
|
||||
TURN_SECRET=YOUR_TURN_SHARED_SECRET
|
||||
```
|
||||
|
||||
Generating secrets with OpenSSL:
|
||||
|
||||
```bash
|
||||
# Generate DB_PASSWORD, LK_SECRET, and TURN_SECRET
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Reverse Proxy and Client Discovery (.well-known)
|
||||
|
||||
A reverse proxy routes external HTTPS traffic to internal services and satisfies protocol requirements for web clients:
|
||||
|
||||
1. **CORS Headers**: Web clients like Element Web or Element Call make cross-origin API requests. The proxy must return appropriate `Access-Control-Allow-*` headers.
|
||||
2. **WebSocket Proxying (`/livekit/`)**: LiveKit signaling requires persistent WebSocket connections (`Upgrade: $http_upgrade` and `Connection: "Upgrade"`).
|
||||
3. **Auto-Discovery (`/.well-known/matrix/client` & `/.well-known/matrix/server`)**: Matrix clients inspect these URIs upon user login to locate the homeserver API base URL and LiveKit JWT service without asking users to type custom port numbers or subdomains.
|
||||
|
||||
The following **Nginx** configuration puts all routing rules into practice:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 443 ssl http2;
|
||||
server_name matrix.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
|
||||
|
||||
location /_matrix {
|
||||
proxy_pass http://synapse:8008;
|
||||
proxy_set_header Host $host;
|
||||
# CORS Headers for Web Clients (Element Web / Element Call)
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Matrix-User-ID" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Matrix Client & Federation API
|
||||
location /_matrix {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# LiveKit SFU Signal (WebSockets)
|
||||
location /livekit/ {
|
||||
rewrite ^/livekit/(.*) /$1 break;
|
||||
proxy_pass http://livekit:7880;
|
||||
proxy_pass http://127.0.0.1:7880;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# LiveKit JWT Token Service for Element X
|
||||
location /livekit/jwt/ {
|
||||
rewrite ^/livekit/jwt/(.*) /$1 break;
|
||||
proxy_pass http://lk-jwt:8080;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Matrix Client Well-Known Auto-Discovery
|
||||
location /.well-known/matrix/client {
|
||||
default_type application/json;
|
||||
return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4186.stable":{"enabled":true},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://matrix.example.com/livekit/jwt"}]}';
|
||||
}
|
||||
|
||||
# Matrix Server Well-Known Federation Discovery
|
||||
location /.well-known/matrix/server {
|
||||
default_type application/json;
|
||||
return 200 '{"m.server":"matrix.example.com:443"}';
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. User Registration
|
||||
## 7. Account Provisioning and Maintenance
|
||||
|
||||
### Manual Registration (Admin)
|
||||
With public registration disabled (`enable_registration: false`), accounts are provisioned via Synapse's CLI tool.
|
||||
|
||||
For private servers, disable public registration in `homeserver.yaml` and add users via CLI:
|
||||
### Admin User Registration
|
||||
|
||||
Run `register_new_matrix_user` inside the Synapse container to create your initial administrator account:
|
||||
|
||||
```bash
|
||||
docker exec -it matrix-synapse register_new_matrix_user \
|
||||
-c /data/homeserver.yaml http://localhost:8008 \
|
||||
--user <username> --password <password> --admin
|
||||
|
||||
--user admin --password "SECURE_USER_PASSWORD" --admin
|
||||
```
|
||||
|
||||
### SMTP (External Sign-up)
|
||||
### Transactional Email Configuration (SMTP)
|
||||
|
||||
Add to `homeserver.yaml` to allow email verification:
|
||||
To enable email verification and password reset functionality, append the `email` configuration block to `homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
smtp_host: 'smtp.gmail.com'
|
||||
smtp_host: "smtp.example.com"
|
||||
smtp_port: 587
|
||||
smtp_user: 'yourname@gmail.com'
|
||||
smtp_pass: 'xxxx xxxx xxxx xxxx' # Google App Password
|
||||
force_tls: true
|
||||
smtp_user: "notifications@example.com"
|
||||
smtp_pass: "YOUR_SMTP_PASSWORD"
|
||||
require_transport_security: true
|
||||
notif_from: "Matrix Server <notifications@example.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Synapse GitHub](https://github.com/element-hq/synapse)
|
||||
- [LiveKit GitHub](https://github.com/livekit/livekit)
|
||||
- [lk-jwt-service GitHub](https://github.com/element-hq/lk-jwt-service)
|
||||
- [Element X Apps](https://www.google.com/search?q=https://element.io/element-x)
|
||||
- [Synapse Documentation](https://element-hq.github.io/synapse/)
|
||||
- [LiveKit Server Documentation](https://docs.livekit.io/)
|
||||
- [Element lk-jwt-service GitHub](https://github.com/element-hq/lk-jwt-service)
|
||||
- [MSC4143: Native Matrix RTC Foci](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
|
||||
- [MSC4186: LiveKit Call Authentication](https://github.com/matrix-org/matrix-spec-proposals/pull/4186)
|
||||
@@ -1,280 +1,435 @@
|
||||
---
|
||||
title: 'Руководство по развёртыванию Matrix-сервера'
|
||||
description: 'Развёртывание серверов Synapse и LiveKit с нуля'
|
||||
description: 'Разбор архитектуры и развёртывание Synapse, Coturn, LiveKit SFU и Native Matrix RTC'
|
||||
createdDate: 2026-02-15
|
||||
tags: ['matrix', 'deployment', 'guide']
|
||||
tags: ['self-hosting', 'guides', 'matrix']
|
||||
---
|
||||
|
||||
Это руководство предназначено **только для образовательных целей**. В нем показано, как развернуть приватную коммуникационную инфраструктуру, чтобы лучше понять децентрализованный протокол Matrix и техническую реализацию медиа-релеев.
|
||||
Развёртывание полноценного стека связи Matrix не ограничивается запуском единого веб-сервера. Matrix представляет собой открытый децентрализованный протокол для безопасного сквозного шифрования сообщений и голосовой связи в реальном времени. Если для текстовых чатов достаточно базового homeserver и базы данных, то современные мультимедийные возможности — от личных WebRTC-звонков 1:1 до групповых видеоконференций — требуют специализированных медиа-релеев и мостов аутентификации.
|
||||
|
||||
## Зачем поднимать сервер самостоятельно?
|
||||
|
||||
Централизованные платформы нередко жертвуют приватностью данных ради аналитики или зависят от внешних изменений политики. При развертывании собственного Matrix-инстанса вы получаете:
|
||||
|
||||
- **Суверенитет данных**: железо и база данных находятся под вашим контролем.
|
||||
- **Сквозное шифрование (E2EE)**: сообщения шифруются локально на устройствах, сервер передает только шифртекст.
|
||||
- **Устойчивость инфраструктуры**: ваши коммуникации не зависят от простоев крупных провайдеров.
|
||||
- **Приватность метаданных**: вы сами определяете, какие логи о взаимодействиях пользователей сохраняются.
|
||||
В этой статье подробно разбирается устройство и пошаговое развёртывание производственного стека Matrix на базе **Synapse**, **PostgreSQL**, **Coturn**, **LiveKit SFU** и **lk-jwt-service**.
|
||||
|
||||
---
|
||||
|
||||
## Обзор технического стека
|
||||
## Архитектура технического стека
|
||||
|
||||
1. **Synapse**: "homeserver", который реализует Matrix-протокол, логику учетных записей и синхронизацию сообщений.
|
||||
2. **Postgres**: база данных. Для production обязательна по соображениям производительности (SQLite подходит только для тестов).
|
||||
3. **Coturn (STUN/TURN)**: при звонках между двумя участниками фаерволы часто мешают прямому соединению. Coturn работает как релей и помогает обойти NAT-ограничения.
|
||||
4. **LiveKit**: современный медиадвижок (SFU). Если Coturn нужен в первую очередь для 1:1-звонков, то LiveKit эффективно обрабатывает групповые звонки через мультиплексирование аудио/видеопотоков.
|
||||
5. **lk-jwt-service**: сервис-аутентификатор. Проверяет, что пользователь авторизован в вашем Synapse-сервере, и выдает временный токен (JWT) для входа в звонок.
|
||||
Чтобы понять взаимодействие компонентов, разберем задачи каждого из пяти ключевых сервисов:
|
||||
|
||||
1. **Synapse**: Основной движок homeserver. Управляет аутентификацией пользователей, состоянием комнат, синхронизацией сообщений, обменом ключами сквозного шифрования (E2EE) и федерацией с другими серверами в глобальной сети Matrix.
|
||||
2. **PostgreSQL**: Реляционная база данных для Synapse. Если для коротких локальных тестов Synapse допускает SQLite, то в продакшене PostgreSQL обязателен для обработки параллельных транзакций, индексации комнат и истории сообщений.
|
||||
3. **Coturn (STUN/TURN)**: Сервер ретрансляции для обхода NAT. При звонке 1:1 устройства пытаются установить прямое peer-to-peer WebRTC-соединение. Если между участниками есть симметричный NAT или фаервол, Coturn выступает в роли ретранслятора, передавая зашифрованные аудио- и видеопакеты по протоколам UDP или TCP.
|
||||
4. **LiveKit SFU**: Высокопроизводительный Selective Forwarding Unit. Соединение peer-to-peer становится неэффективным при групповых звонках от трех участников. LiveKit принимает видео/аудиопотоки от каждого спикера и перенаправляет их остальным участникам, существенно экономя исходящий интернет-трафик клиентов.
|
||||
5. **lk-jwt-service**: Мост аутентификации для Element Call и Element X Native Matrix RTC (стандарты MSC4143 и MSC4186). Когда пользователь входит в групповой звонок в Element X, `lk-jwt-service` проверяет его сессию в Synapse и генерирует временный токен JWT для подключения к комнате LiveKit.
|
||||
6. **Обратный прокси (Traefik / Nginx)**: Входной шлюз. Терминирует SSL-сертификаты, обрабатывает WebSocket-соединения для сигнализации LiveKit (`/livekit`), проставляет заголовки CORS для веб-клиентов и отдаёт файлы автообнаружения `.well-known`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Стек развертывания
|
||||
## 1. Инфраструктура Docker (docker-compose.yml)
|
||||
|
||||
Эта конфигурация универсальна, использует **Docker Volumes** и настроена под **UID 1000:1000** для базового харднинга.
|
||||
Контейнеризация обеспечит изоляцию сервисов, правильный порядок их запуска и предсказуемую сетевую связность.
|
||||
|
||||
В нашем файле compose для PostgreSQL настроена проверка готовности (`pg_isready`), чтобы Synapse не запускался до полного создания базы данных. Дополнительные сервисы, такие как Coturn, LiveKit и `lk-jwt-service`, используют сетевой режим `network_mode: "service:synapse"` или выделенные сети контейнеров, что пробрасывает порты WebRTC и STUN/TURN непосредственно на хост.
|
||||
|
||||
Для корректной передачи медиапотоков открываются следующие диапазона портов:
|
||||
- **Порты 3478 и 5349 (TCP/UDP)**: Сигнализация Coturn STUN и TURN.
|
||||
- **Диапазон 49152-49172 (UDP)**: Динамические каналы ретрансляции медиа Coturn.
|
||||
- **Порт 7880 (TCP)**: HTTP-сигнализация и WebSockets в LiveKit.
|
||||
- **Порт 7881 (TCP)**: Резервный TCP-порт для WebRTC в LiveKit.
|
||||
- **Диапазон 50000-50020 (UDP)**: Каналы передаче медиа в LiveKit SFU.
|
||||
|
||||
Итоговый файл `docker-compose.yml` содержит следующую конфигурацию:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
proxy:
|
||||
name: matrix-proxy
|
||||
internal:
|
||||
internal: true
|
||||
matrix:
|
||||
name: matrix-network
|
||||
|
||||
volumes:
|
||||
synapse_data:
|
||||
postgres_data:
|
||||
traefik_certs:
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: matrix-proxy
|
||||
command:
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.web.http.redirections.entrypoint.to=websecure'
|
||||
- '--certificatesresolvers.myresolver.acme.dnschallenge.provider=cloudflare'
|
||||
- '--certificatesresolvers.myresolver.acme.email=${ADMIN_EMAIL}'
|
||||
- '--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json'
|
||||
environment:
|
||||
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- traefik_certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- proxy
|
||||
restart: unless-stopped
|
||||
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.146.0
|
||||
container_name: matrix-synapse
|
||||
user: '1000:1000'
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
volumes:
|
||||
- synapse_data:/data
|
||||
environment:
|
||||
SYNAPSE_SERVER_NAME: '${DOMAIN}'
|
||||
SYNAPSE_REPORT_STATS: 'no'
|
||||
SYNAPSE_CONFIG_PATH: '/data/homeserver.yaml'
|
||||
networks:
|
||||
- proxy
|
||||
- internal
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.synapse.rule=Host(`${DOMAIN}`)'
|
||||
- 'traefik.http.services.synapse.loadbalancer.server.port=8008'
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: matrix-db
|
||||
user: '1000:1000'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: synapse
|
||||
POSTGRES_USER: synapse
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C'
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U synapse']
|
||||
interval: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
container_name: matrix-livekit
|
||||
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||
environment:
|
||||
LIVEKIT_KEYS: '${LK_KEY}: ${LK_SECRET}'
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7881:7881'
|
||||
- '50000-50020:50000-50020/udp'
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit`)'
|
||||
- 'traefik.http.middlewares.lk-strip.stripprefix.prefixes=/livekit'
|
||||
- 'traefik.http.routers.lk.middlewares=lk-strip'
|
||||
- matrix
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U synapse -d synapse']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
lk-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.1
|
||||
container_name: matrix-lk-jwt
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.146.0
|
||||
container_name: matrix-synapse
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LIVEKIT_URL: wss://${DOMAIN}/livekit
|
||||
LIVEKIT_KEY: '${LK_KEY}'
|
||||
LIVEKIT_SECRET: '${LK_SECRET}'
|
||||
SYNAPSE_SERVER_NAME: '${DOMAIN}'
|
||||
SYNAPSE_REPORT_STATS: 'no'
|
||||
SYNAPSE_CONFIG_PATH: '/data/homeserver.yaml'
|
||||
volumes:
|
||||
- ./homeserver.yaml:/data/homeserver.yaml:ro
|
||||
- synapse_data:/data
|
||||
networks:
|
||||
- proxy
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.lk-jwt.rule=Host(`${DOMAIN}`) && PathPrefix(`/livekit/jwt`)'
|
||||
- 'traefik.http.middlewares.lk-jwt-strip.stripprefix.prefixes=/livekit/jwt'
|
||||
- 'traefik.http.routers.lk-jwt.middlewares=lk-jwt-strip'
|
||||
- matrix
|
||||
ports:
|
||||
# Matrix Client & Federation HTTP API
|
||||
- '8008:8008'
|
||||
# Порты сигнализации Coturn STUN/TURN
|
||||
- '3478:3478/tcp'
|
||||
- '3478:3478/udp'
|
||||
- '5349:5349/tcp'
|
||||
- '5349:5349/udp'
|
||||
# Диапазон UDP-портов для медиа-релея Coturn
|
||||
- '49152-49172:49152-49172/udp'
|
||||
# Порты LiveKit HTTP/WebSocket и WebRTC
|
||||
- '7880:7880/tcp'
|
||||
- '7881:7881/tcp'
|
||||
- '50000-50020:50000-50020/udp'
|
||||
restart: unless-stopped
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:4.8-alpine
|
||||
container_name: matrix-coturn
|
||||
user: '1000:1000'
|
||||
ports:
|
||||
- '3478:3478'
|
||||
- '3478:3478/udp'
|
||||
- '5349:5349'
|
||||
- '5349:5349/udp'
|
||||
- '49152-49172:49152-49172/udp'
|
||||
depends_on:
|
||||
- synapse
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
network_mode: 'service:synapse'
|
||||
command: ['-c', '/etc/coturn/turnserver.conf']
|
||||
restart: unless-stopped
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9
|
||||
container_name: matrix-livekit
|
||||
depends_on:
|
||||
- synapse
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
environment:
|
||||
LIVEKIT_KEYS: '${LK_KEY}: ${LK_SECRET}'
|
||||
network_mode: 'service:synapse'
|
||||
command: --config /etc/livekit.yaml --node-ip ${SERVER_IP}
|
||||
restart: unless-stopped
|
||||
|
||||
lk-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.1
|
||||
container_name: matrix-lk-jwt
|
||||
depends_on:
|
||||
- synapse
|
||||
environment:
|
||||
LK_JWT_PORT: 8080
|
||||
LIVEKIT_URL: wss://${DOMAIN}/livekit
|
||||
LIVEKIT_KEY: '${LK_KEY}'
|
||||
LIVEKIT_SECRET: '${LK_SECRET}'
|
||||
network_mode: 'service:synapse'
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфигурация
|
||||
## 2. Конфигурация Homeserver (homeserver.yaml)
|
||||
|
||||
Создайте файл `.env` в той же директории. **Не пропускайте шаг генерации секретов ниже.**
|
||||
Synapse использует `homeserver.yaml` для определения правил работы. Разберем ключевые блоки:
|
||||
|
||||
```bash
|
||||
# --- Infrastructure ---
|
||||
DOMAIN=matrix.example.com
|
||||
SERVER_IP=1.2.3.4
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
- **Идентификация сервера (`server_name`)**: Определяет доменное имя вашего сервера и суффикс ID пользователей (например, `@user:matrix.example.com`). Изменение этого имени в будущем приведет к потере связей с другими серверами и поломке ID комнат.
|
||||
- **Подключение к базе данных (`database`)**: Настраивает подключение к PostgreSQL через модуль `psycopg2`. Параметры `cp_min` и `cp_max` управляют пулом соединений при нагрузке.
|
||||
- **Ротация медиафайлов (`media_retention`)**: Участники комнат регулярно отправляют фото и файлы. Параметр `remote_media_lifetime: 14d` регулярно удаляет кэш файлов с чужих серверов, сохраняя дисковое пространство (локальные файлы хранятся в течение года `local_media_lifetime: 365d`).
|
||||
- **Интеграция VoIP (`turn_uris` и `turn_shared_secret`)**: Передает Synapse параметры Coturn. При звонке 1:1 Synapse с помощью `turn_shared_secret` генерирует временные логин и пароль для безопасного подключения к TURN-релею.
|
||||
- **Native Matrix RTC (MSC4143 / MSC4186)**: Современные клиенты, такие как Element X и Element Call, используют спецификации MSC4143 (RTC Foci) и MSC4186 (LiveKit Call Authentication). Блок `extra_well_known_client_content` сообщает клиенту, что выпиской токенов для звонков занимается сервис `lk-jwt-service` по адресу `https://matrix.example.com/livekit/jwt`.
|
||||
|
||||
# --- Cloudflare (for Traefik SSL) ---
|
||||
CF_DNS_API_TOKEN=your_cloudflare_api_token_here
|
||||
Полный файл `homeserver.yaml` выглядит следующим образом:
|
||||
|
||||
# --- Database ---
|
||||
DB_PASSWORD=generate_this_with_openssl
|
||||
```yaml
|
||||
# Идентификация сервера
|
||||
server_name: "matrix.example.com"
|
||||
pid_file: /data/homeserver.pid
|
||||
public_baseurl: "https://matrix.example.com"
|
||||
|
||||
# --- LiveKit Secrets ---
|
||||
# API Key can be any alphanumeric string
|
||||
LK_KEY=matrix_lk_key
|
||||
# Secret MUST be generated
|
||||
LK_SECRET=generate_this_with_openssl
|
||||
# Слушатели (Listeners)
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
resources:
|
||||
- names: [client, federation]
|
||||
compress: false
|
||||
|
||||
# --- Coturn / Synapse VoIP ---
|
||||
# Used for STUN/TURN authentication
|
||||
TURN_SECRET=generate_this_with_openssl
|
||||
# База данных
|
||||
database:
|
||||
name: psycopg2
|
||||
args:
|
||||
user: "synapse"
|
||||
password: "YOUR_POSTGRES_PASSWORD"
|
||||
database: "synapse"
|
||||
host: "postgres"
|
||||
cp_min: 5
|
||||
cp_max: 10
|
||||
|
||||
```
|
||||
# Безопасность и регистрация
|
||||
enable_registration: false
|
||||
enable_registration_captcha: false
|
||||
registration_shared_secret: "YOUR_REGISTRATION_SHARED_SECRET"
|
||||
|
||||
### Генерация секретов
|
||||
# Хранение файлов и ротация
|
||||
media_store_path: "/data/media_store"
|
||||
max_upload_size: "50M"
|
||||
max_image_pixels: "32M"
|
||||
dynamic_thumbnails: true
|
||||
|
||||
Используйте `openssl`, чтобы создать надежные случайные строки для параметров в `.env`:
|
||||
media_retention:
|
||||
remote_media_lifetime: 14d
|
||||
local_media_lifetime: 365d
|
||||
|
||||
```bash
|
||||
# Generate DB_PASSWORD, LK_SECRET, and TURN_SECRET
|
||||
openssl rand -hex 32
|
||||
caches:
|
||||
global_factor: 0.5
|
||||
|
||||
# VoIP / Coturn (Звонки 1:1)
|
||||
turn_uris:
|
||||
- "turn:matrix.example.com:3478?transport=udp"
|
||||
- "turn:matrix.example.com:3478?transport=tcp"
|
||||
turn_shared_secret: "YOUR_TURN_SHARED_SECRET"
|
||||
turn_user_lifetime: 1h
|
||||
turn_allow_guests: true
|
||||
|
||||
# Native Matrix RTC (MSC4143 / MSC4186 для звонков в Element X)
|
||||
experimental_features:
|
||||
msc3266_enabled: true
|
||||
msc4222_enabled: true
|
||||
|
||||
serve_server_wellknown: true
|
||||
extra_well_known_client_content:
|
||||
"org.matrix.msc4186.stable": {"enabled": true}
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
|
||||
report_stats: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Вариант с Nginx и сертификатами
|
||||
## 3. Конфигурация LiveKit SFU (livekit.yaml)
|
||||
|
||||
Если вы не используете Traefik, получите сертификаты через **Certbot**:
|
||||
LiveKit распределяет медиапотоки участников групповых звонков. Основные директивы в `livekit.yaml`:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d matrix.example.com
|
||||
- **`bind_addresses` и `port`**: Задает служебный HTTP-порт и WebSockets на порту `7880`.
|
||||
- **`rtc.use_external_ip: true`**: Важнейшая опция для облачных серверов за NAT. Указывает LiveKit определять публичный IP-адрес хоста (`--node-ip`) и передавать его подключающимся клиентам.
|
||||
- **`rtc.port_range_start` и `rtc.port_range_end`**: Ограничивает диапазон динамических UDP-портов WebRTC от `50000` до `50020`.
|
||||
- **`room.auto_create: true`**: Разрешает автоматическое создание комнат при получении валидного JWT-токена от `lk-jwt-service`.
|
||||
|
||||
Конфигурационный файл `livekit.yaml`:
|
||||
|
||||
```yaml
|
||||
port: 7880
|
||||
bind_addresses:
|
||||
- 0.0.0.0
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 50020
|
||||
use_external_ip: true
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
|
||||
logging:
|
||||
level: info
|
||||
```
|
||||
|
||||
### Конфиг Nginx
|
||||
---
|
||||
|
||||
## 4. Конфигурация Coturn Relay (turnserver.conf)
|
||||
|
||||
Coturn обеспечивает ретрансляцию трафика для 1:1 звонков. Ключевые опции в `turnserver.conf`:
|
||||
|
||||
- **`listening-port=3478`**: Стандартный порт для приёма STUN/TURN запросов по UDP и TCP.
|
||||
- **`external-ip`**: Задает публичный IP-адрес сервера для корректного формирования адресов ретрансляции.
|
||||
- **`min-port` и `max-port`**: Ограничивает диапазоны динамических UDP-портов от `49152` до `49172`.
|
||||
- **`use-auth-secret` и `static-auth-secret`**: Включает авторизацию по секрету, совпадающему с `turn_shared_secret` в `homeserver.yaml`.
|
||||
- **`realm`**: Доменное имя сервера, необходимое для проверки HMAC-токенов.
|
||||
- **`no-multicast-peers`**: Блокирует попытки использовать релей для сканирования внутренних адресов сети.
|
||||
|
||||
Файл `turnserver.conf` формируется следующим образом:
|
||||
|
||||
```conf
|
||||
listening-port=3478
|
||||
listening-ip=0.0.0.0
|
||||
|
||||
# Публичный IP-адрес вашего сервера
|
||||
external-ip=203.0.113.10
|
||||
|
||||
min-port=49152
|
||||
max-port=49172
|
||||
|
||||
fingerprint
|
||||
use-auth-secret
|
||||
static-auth-secret=YOUR_TURN_SHARED_SECRET
|
||||
realm=matrix.example.com
|
||||
|
||||
total-quota=100
|
||||
stale-nonce=600
|
||||
log-file=stdout
|
||||
simple-log
|
||||
no-multicast-peers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Переменные окружения и генерация секретов (.env)
|
||||
|
||||
Вынос паролей и ключей в переменные окружения предотвращает их попадание в конфигурационные файлы и репозитории.
|
||||
|
||||
Секреты `DB_PASSWORD`, `LK_SECRET` и `TURN_SECRET` должны быть устойчивыми криптографическими строками. Мы используем `openssl` для их генерации.
|
||||
|
||||
Шаблон файла `.env`:
|
||||
|
||||
```bash
|
||||
# Инфраструктура
|
||||
DOMAIN=matrix.example.com
|
||||
SERVER_IP=203.0.113.10
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# База данных
|
||||
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
|
||||
|
||||
# LiveKit (Групповые звонки)
|
||||
LK_KEY=matrix_lk_key
|
||||
LK_SECRET=YOUR_LIVEKIT_SECRET
|
||||
|
||||
# Coturn (Звонки 1:1)
|
||||
TURN_SECRET=YOUR_TURN_SHARED_SECRET
|
||||
```
|
||||
|
||||
Генерация секретов через OpenSSL:
|
||||
|
||||
```bash
|
||||
# Выполните для создания DB_PASSWORD, LK_SECRET и TURN_SECRET
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Обратный прокси и автообнаружение (.well-known)
|
||||
|
||||
Обратный прокси перенаправляет внешние запросы к нужным сервисам и выполняет требования веб-клиентов:
|
||||
|
||||
1. **Заголовки CORS**: Веб-клиенты Element Web и Element Call выполняют междоменные запросы. Прокси должен возвращать заголовки `Access-Control-Allow-*`.
|
||||
2. **Проксирование WebSockets (`/livekit/`)**: Для работы LiveKit необходимо проксирование постоянных WebSocket-соединений (`Upgrade: $http_upgrade` и `Connection: "Upgrade"`).
|
||||
3. **Автообнаружение (`/.well-known/matrix/client` и `/.well-known/matrix/server`)**: Клиенты Matrix запрашивают эти URL при входе, чтобы автоматически определить адрес сервера и сервиса LiveKit JWT без ввода специальных портов пользователем.
|
||||
|
||||
Ниже приведен готовый пример конфигурации **Nginx**:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 443 ssl http2;
|
||||
server_name matrix.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
|
||||
|
||||
location /_matrix {
|
||||
proxy_pass http://synapse:8008;
|
||||
proxy_set_header Host $host;
|
||||
# CORS-заголовки для веб-клиентов (Element Web / Element Call)
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Matrix-User-ID" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# API клиентов и федерации Matrix
|
||||
location /_matrix {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# Сигнализация LiveKit SFU (WebSockets)
|
||||
location /livekit/ {
|
||||
rewrite ^/livekit/(.*) /$1 break;
|
||||
proxy_pass http://livekit:7880;
|
||||
proxy_pass http://127.0.0.1:7880;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Сервис выписки JWT-токенов LiveKit для Element X
|
||||
location /livekit/jwt/ {
|
||||
rewrite ^/livekit/jwt/(.*) /$1 break;
|
||||
proxy_pass http://lk-jwt:8080;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Автообнаружение клиента Matrix (.well-known)
|
||||
location /.well-known/matrix/client {
|
||||
default_type application/json;
|
||||
return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4186.stable":{"enabled":true},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://matrix.example.com/livekit/jwt"}]}';
|
||||
}
|
||||
|
||||
# Автообнаружение федерации Matrix (.well-known)
|
||||
location /.well-known/matrix/server {
|
||||
default_type application/json;
|
||||
return 200 '{"m.server":"matrix.example.com:443"}';
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Регистрация пользователей
|
||||
## 7. Создание аккаунтов и обслуживание
|
||||
|
||||
### Ручная регистрация (администратором)
|
||||
При отключенной публичной регистрации (`enable_registration: false`) новые аккаунты создаются вручную.
|
||||
|
||||
Для приватных серверов отключите публичную регистрацию в `homeserver.yaml` и добавляйте пользователей через CLI:
|
||||
### Регистрация администратора через CLI
|
||||
|
||||
Выполните команду `register_new_matrix_user` внутри контейнера Synapse:
|
||||
|
||||
```bash
|
||||
docker exec -it matrix-synapse register_new_matrix_user \
|
||||
-c /data/homeserver.yaml http://localhost:8008 \
|
||||
--user <username> --password <password> --admin
|
||||
|
||||
--user admin --password "SECURE_USER_PASSWORD" --admin
|
||||
```
|
||||
|
||||
### SMTP (внешняя регистрация)
|
||||
### Настройка SMTP для писем
|
||||
|
||||
Добавьте в `homeserver.yaml`, чтобы включить подтверждение по email:
|
||||
Для включения функции восстановления паролей и подтверждения почты добавьте блок `email` в `homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
smtp_host: 'smtp.gmail.com'
|
||||
smtp_host: "smtp.example.com"
|
||||
smtp_port: 587
|
||||
smtp_user: 'yourname@gmail.com'
|
||||
smtp_pass: 'xxxx xxxx xxxx xxxx' # Google App Password
|
||||
force_tls: true
|
||||
smtp_user: "notifications@example.com"
|
||||
smtp_pass: "YOUR_SMTP_PASSWORD"
|
||||
require_transport_security: true
|
||||
notif_from: "Matrix Server <notifications@example.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ресурсы
|
||||
|
||||
- [Synapse GitHub](https://github.com/element-hq/synapse)
|
||||
- [LiveKit GitHub](https://github.com/livekit/livekit)
|
||||
- [lk-jwt-service GitHub](https://github.com/element-hq/lk-jwt-service)
|
||||
- [Element X Apps](https://www.google.com/search?q=https://element.io/element-x)
|
||||
- [Документация Synapse](https://element-hq.github.io/synapse/)
|
||||
- [Документация LiveKit Server](https://docs.livekit.io/)
|
||||
- [Element lk-jwt-service GitHub](https://github.com/element-hq/lk-jwt-service)
|
||||
- [MSC4143: Native Matrix RTC Foci](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
|
||||
- [MSC4186: LiveKit Call Authentication](https://github.com/matrix-org/matrix-spec-proposals/pull/4186)
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
title: 'Hello World'
|
||||
description: 'My first article using the new content system.'
|
||||
createdDate: 2026-01-31
|
||||
tags: ['test', 'general']
|
||||
---
|
||||
|
||||
## Section 1
|
||||
|
||||
Some content here.
|
||||
|
||||
```typescript
|
||||
const hello = 'world';
|
||||
console.log(hello);
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
title: 'Привет, мир'
|
||||
description: 'Моя первая статья в новой системе контента.'
|
||||
createdDate: 2026-01-31
|
||||
tags: ['test', 'general']
|
||||
---
|
||||
|
||||
## Раздел 1
|
||||
|
||||
Немного содержимого здесь.
|
||||
|
||||
```typescript
|
||||
const hello = 'world';
|
||||
console.log(hello);
|
||||
```
|
||||
@@ -2,77 +2,195 @@
|
||||
title: 'Xray Subnet Mimicry (10.0.0.0/8)'
|
||||
description: 'Building a distributed private network with Xray-core and reverse tunneling'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['networking', 'xray', 'guide']
|
||||
tags: ['networking', 'guides', 'xray']
|
||||
---
|
||||
|
||||
> **EDUCATIONAL PURPOSES ONLY**
|
||||
> **EDUCATIONAL PURPOSES ONLY**
|
||||
> This project is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
|
||||
|
||||
This guide describes how to deploy a private note-taking infrastructure (or any distributed service) using a contiguous `10.0.0.0/8` private subnet mimicked across distinct servers using **Xray-core** and **reverse tunneling**.
|
||||
This guide describes how to deploy a private, distributed communications infrastructure across distinct remote servers using a contiguous `10.0.0.0/8` private subnet mimicked via **Xray-core**, **TPROXY**, and **VLESS reverse tunneling**.
|
||||
|
||||
---
|
||||
|
||||
## Technical Deep-Dive & Architecture
|
||||
|
||||
### The Virtual Subnet Concept
|
||||
|
||||
We are constructing a "Flat Network" (Layer 3) over the public internet. Each node acts as an authoritative gateway for its own `/16` segment of the `10.0.0.0/8` range. This allows seamless communication between servers as if they were on the same local network.
|
||||
We are constructing a Layer 3 "Flat Network" over the public internet. Each server node acts as an authoritative gateway for its own `/16` segment within the `10.0.0.0/8` private address space:
|
||||
|
||||
- **Server A (Hub / Portal)**: Owns `10.1.0.0/16`
|
||||
- **Server B (Spoke / Bridge)**: Owns `10.2.0.0/16`
|
||||
- **Server C (Spoke / Bridge)**: Owns `10.3.0.0/16`
|
||||
|
||||
This setup enables seamless inter-node communication as if all servers and hosted containers resided on the same local physical network.
|
||||
|
||||
### The Science of Interception (TPROXY)
|
||||
|
||||
Unlike `REDIRECT`, which only works for TCP and modifies the destination IP, **TPROXY** (Transparent Proxy) allows Xray to intercept both TCP and UDP while preserving the original packet headers.
|
||||
Unlike `REDIRECT`, which only supports TCP and modifies packet destination IP headers to match the proxy, **TPROXY** (Transparent Proxy) intercepts both TCP and UDP packets while preserving original source and destination IP headers.
|
||||
|
||||
- **Mechanism**: When a packet destined for `10.x.x.x` enters the `PREROUTING` or `OUTPUT` chain, the Linux Mangle table marks it with `0x1`.
|
||||
- **Preservation**: Xray's `tunnel` inbound uses the `tproxy` socket option to read the original destination IP from the socket metadata, enabling it to route the packet correctly.
|
||||
- **Marking**: When a packet destined for `10.x.x.x` enters the `PREROUTING` or `OUTPUT` iptables chain, the Mangle table marks it with `0x1`.
|
||||
- **Preservation**: Xray's `tunnel` inbound listens with `"sockopt": { "tproxy": "tproxy" }`, allowing it to read the original destination IP from socket metadata and route packets transparently.
|
||||
|
||||
### Policy Routing (Table 100)
|
||||
|
||||
While firewall marks identify intercepted packets, they don't inherently change the routing path. To redirect this traffic to Xray, we use **Policy Routing**:
|
||||
Firewall marks alone do not alter routing paths; standard Linux routing tables ignore firewall marks unless directed by **Policy Routing**:
|
||||
|
||||
1. **The Rule (`ip rule`)**: `ip rule add fwmark 1 lookup 100` instructs the networking stack to use a custom routing table for marked packets.
|
||||
2. **The Table (`ip route`)**: Table 100 is configured with `local default dev lo`, treating all destination IPs as local and routing them to the loopback interface.
|
||||
3. **The Handoff**: Xray, listening on `127.0.0.1:12345` with the `tproxy` option, intercepts the packet from the loopback interface.
|
||||
4. **Loop Prevention**: We use `mark: 255` in Xray's outbounds to exempt its own traffic from interception, avoiding infinite routing loops.
|
||||
1. **The Rule (`ip rule`)**: `ip rule add fwmark 0x1 lookup 100 priority 100` tells the kernel to route any packet marked with `0x1` using custom routing table 100.
|
||||
2. **The Table (`ip route`)**: Table 100 contains `ip route add local 0.0.0.0/0 dev lo table 100`, forcing marked packets onto the local loopback interface.
|
||||
3. **The Handoff**: Xray listens on `127.0.0.1:12345` with TPROXY enabled, grabbing the packet from loopback.
|
||||
4. **Loop Prevention**: Xray outbounds append `"sockopt": { "mark": 255 }` (`0xff`) to bypass TPROXY interception rules for its own generated traffic, preventing infinite routing loops.
|
||||
|
||||
### The Reverse Proxy Mechanism
|
||||
---
|
||||
|
||||
To reach servers behind NAT (Spokes) from a central Hub, Xray employs a sophisticated Reverse Proxy architecture:
|
||||
## 1. Setup Phase and Key Generation
|
||||
|
||||
- **Bridge (Spoke)**: Initiates the connection to the Portal. It acts as a client for the tunnel but functionally behaves as a server once the connection is established.
|
||||
- **Portal (Hub)**: Listens for Bridge connections and uses "Dummy Domains" to identify them, relaying traffic through the established tunnels.
|
||||
- **Multiplexing (Mux)**: Uses [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) to pack hundreds of virtual streams into a single physical TLS connection, reducing overhead and bypassing NAT timeouts.
|
||||
Generate four unique UUIDs to secure the VLESS tunnel connections between the Hub and Spokes:
|
||||
|
||||
## Setup Guide
|
||||
- `UUID_B_REVERSE`: Tunnel control for Server B (Bridge)
|
||||
- `UUID_B_DIRECT`: Data traffic for Server B
|
||||
- `UUID_C_REVERSE`: Tunnel control for Server C (Bridge)
|
||||
- `UUID_C_DIRECT`: Data traffic for Server C
|
||||
|
||||
### Phase 0: Prerequisites (Server A only)
|
||||
---
|
||||
|
||||
Server A (the Hub) requires a valid SSL certificate for XTLS-Vision. Point your domain (`A-DOMAIN`) to Server A's public IP and ensure certificates are available (e.g., via Certbot) at `/etc/letsencrypt/live/A-DOMAIN/`.
|
||||
## 2. Host Networking and TPROXY (routing.sh and before.rules)
|
||||
|
||||
### Phase 1: Security & Identity
|
||||
Before launching Xray containers, each server node requires kernel routing policy rules and UFW `mangle` firewall chains.
|
||||
|
||||
Each connection requires a unique UUID. Generate 4 UUIDs to map the following connections:
|
||||
### A. Kernel Routing Initialization (routing.sh)
|
||||
|
||||
- `UUID_B_REVERSE`: Tunnel control for Server B.
|
||||
- `UUID_B_DIRECT`: Data traffic for Server B.
|
||||
- `UUID_C_REVERSE`: Tunnel control for Server C.
|
||||
- `UUID_C_DIRECT`: Data traffic for Server C.
|
||||
The shell script [`routing.sh`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/routing.sh) enables IPv4 forwarding, registers firewall mark `0x1` to lookup table 100, and routes all table 100 traffic to loopback (`dev lo`).
|
||||
|
||||
### Phase 2: Host Networking
|
||||
Run this script on **all** server nodes:
|
||||
|
||||
On **EVERY** server node, perform the following:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
1. **Initialize Routing**: Run the routing setup script to configure kernel parameters and Table 100.
|
||||
2. **Firewall Configuration**: Update `/etc/ufw/before.rules`. Insert the specific node rules immediately after the `*filter` header. These rules handle the `mangle` table logic required for TPROXY.
|
||||
3. **Reload**: Apply changes with `sudo ufw reload`.
|
||||
echo "Applying routing configuration..."
|
||||
|
||||
### Phase 3: Deployment
|
||||
# 1. Enable IP Forwarding
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
Update your `xray.jsonc` configurations with the generated UUIDs, your domain, and public IPs. Launch the stack using `docker compose up -d`. On Server A, the compose setup will also initialize the internal bridge network for services like AdGuard and Nginx.
|
||||
# 2. IP Rule for mark 0x1 -> table 100
|
||||
if sudo ip rule show | grep -q 'fwmark 0x1 lookup 100'; then
|
||||
echo "IP rule for mark 0x1 already exists."
|
||||
else
|
||||
sudo ip rule add fwmark 0x1 lookup 100 priority 100
|
||||
echo "IP rule added for mark 0x1."
|
||||
fi
|
||||
|
||||
## Configuration Examples
|
||||
# 3. Local route in table 100
|
||||
if sudo ip route show table 100 2>/dev/null | grep -q 'local default dev lo'; then
|
||||
echo "Local route in table 100 already exists."
|
||||
else
|
||||
sudo ip route add local 0.0.0.0/0 dev lo table 100
|
||||
echo "Local route added to table 100."
|
||||
fi
|
||||
|
||||
### Server A: Hub (10.1.0.0/16)
|
||||
echo "Routing configuration applied successfully."
|
||||
```
|
||||
|
||||
The Hub handles reverse tunnel entries and routes traffic to the appropriate Spoke based on the 10.x.x.x range.
|
||||
Full configuration available here: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
### B. Hub Firewall Configuration (a/before.rules)
|
||||
|
||||
On **Server A (Hub)**, append the TPROXY rules to `/etc/ufw/before.rules` (after the initial `COMMIT` line).
|
||||
|
||||
The rule logic performs the following steps:
|
||||
1. **Excludes Local Subnet**: Packets targeting Server A's local subnet (`10.1.0.0/16`) are returned to standard processing.
|
||||
2. **Excludes External Traffic**: Packets outside `10.0.0.0/8` pass through unaffected.
|
||||
3. **Excludes Xray Mark (`0xff`)**: Traffic generated by Xray itself is ignored to prevent routing loops.
|
||||
4. **TPROXY Redirection**: Intercepts TCP and UDP traffic targeting `10.0.0.0/8` and redirects it to Xray on `127.0.0.1:12345` with mark `1`.
|
||||
|
||||
Exact repository file: [`a/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/before.rules)
|
||||
|
||||
```ini
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
# --- PREROUTING CHAIN (Traffic from containers/nodes) ---
|
||||
-A XRAY -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
# --- OUTPUT CHAIN (Traffic originating from server host) ---
|
||||
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
# --- DIVERT Optimization ---
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
|
||||
*filter
|
||||
-A ufw-before-forward -s 10.1.0.0/16 -d 10.1.0.0/16 -j ACCEPT
|
||||
-A ufw-before-forward -s 10.1.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
-A ufw-before-input -s 10.1.0.0/16 -d 10.1.0.0/16 -j ACCEPT
|
||||
-A ufw-before-input -s 10.1.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
```
|
||||
|
||||
### C. Spoke Firewall Configuration (b/before.rules and c/before.rules)
|
||||
|
||||
On **Server B (Spoke)** and **Server C (Spoke)**, apply the node-specific `/etc/ufw/before.rules`.
|
||||
|
||||
For Server B (`10.2.0.0/16`), local traffic within `10.2.0.0/16` is exempted (`-d 10.2.0.0/16 -j RETURN`), while any packet destined for the rest of `10.0.0.0/8` (such as `10.1.x.x` or `10.3.x.x`) is intercepted via TPROXY and routed through the VLESS reverse tunnel back to Server A.
|
||||
|
||||
Exact repository files: [`b/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/before.rules) and [`c/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/c/before.rules)
|
||||
|
||||
```ini
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
-A XRAY -d 10.2.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
-A XRAY_MASK -d 10.2.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
|
||||
*filter
|
||||
-A ufw-before-forward -s 10.2.0.0/16 -d 10.2.0.0/16 -j ACCEPT
|
||||
-A ufw-before-forward -s 10.2.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
-A ufw-before-input -s 10.2.0.0/16 -d 10.2.0.0/16 -j ACCEPT
|
||||
-A ufw-before-input -s 10.2.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
```
|
||||
|
||||
Apply the changes on each node with `sudo ufw reload`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Xray-core Configurations (xray.jsonc)
|
||||
|
||||
### A. Hub Configuration (a/xray.jsonc)
|
||||
|
||||
The Hub listens on public port 443 with VLESS and XTLS-Vision. It maps reverse tunnel incoming connections (`reverse-b`, `reverse-c`) to their respective subnet destination rules.
|
||||
|
||||
Exact repository file: [`a/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -83,31 +201,40 @@ Full configuration available here: [a/xray.jsonc](https://github.com/deytenit/xr
|
||||
"tag": "proxy-direct",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
|
||||
// ... repeat for other Spokes
|
||||
],
|
||||
},
|
||||
{ "id": "UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "UUID_B_DIRECT" },
|
||||
{ "id": "UUID_C_REVERSE", "reverse": { "tag": "reverse-c" } },
|
||||
{ "id": "UUID_C_DIRECT" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "proxy-enclave",
|
||||
"port": 12345,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "tunnel",
|
||||
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
|
||||
},
|
||||
"tag": "proxy-enclave",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"tproxy": "tproxy",
|
||||
"mark": 255
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
|
||||
],
|
||||
},
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Server B: Spoke (10.2.0.0/16)
|
||||
### B. Spoke Configuration (b/xray.jsonc)
|
||||
|
||||
The Spoke initiates the reverse connection to the Hub and handles local subnet breakout.
|
||||
Full configuration available here: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
|
||||
The Spoke establishes VLESS client connections back to Server A (`A-EXTERNAL-IP`), maintaining a persistent reverse tunnel for incoming requests and forwarding outbound `10.0.0.0/8` traffic.
|
||||
|
||||
Exact repository files: [`b/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc) and [`c/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/c/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -117,37 +244,39 @@ Full configuration available here: [b/xray.jsonc](https://github.com/deytenit/xr
|
||||
"tag": "forward-a-reverse",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" },
|
||||
},
|
||||
"id": "UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_DIRECT",
|
||||
},
|
||||
},
|
||||
"id": "UUID_B_DIRECT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
|
||||
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
|
||||
],
|
||||
},
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources & Reference
|
||||
|
||||
For a complete, automated example of this architecture, check out the following repository:
|
||||
For the complete, runnable repository containing Docker Compose files ([`a/docker-compose.yaml`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/docker-compose.yaml) and [`b/docker-compose.yaml`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/docker-compose.yaml)), visit:
|
||||
|
||||
- [xray-core-subnet-mimicry on GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
- [xray-core-subnet-mimicry Repository on GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
|
||||
Additional technical references:
|
||||
Technical Documentation & Guides:
|
||||
|
||||
- [Xray-core Official Documentation](https://xtls.github.io/)
|
||||
- [Understanding TPROXY (Cloudflare Blog)](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
|
||||
@@ -2,77 +2,195 @@
|
||||
title: 'Имитация подсети Xray (10.0.0.0/8)'
|
||||
description: 'Создание распределенной частной сети с использованием Xray-core и обратного туннелирования'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['networking', 'xray', 'guide']
|
||||
tags: ['networking', 'guides', 'xray']
|
||||
---
|
||||
|
||||
> **ТОЛЬКО ДЛЯ ОБРАЗОВАТЕЛЬНЫХ ЦЕЛЕЙ**
|
||||
> **ТОЛЬКО ДЛЯ ОБРАЗОВАТЕЛЬНЫХ ЦЕЛЕЙ**
|
||||
> Этот проект разработан как техническая демонстрация возможностей маршрутизации Xray-core и имитации частных подсетей. Он НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
|
||||
|
||||
Это руководство описывает, как развернуть распределенную инфраструктуру (например, для заметок или любого другого сервиса), используя непрерывную частную подсеть `10.0.0.0/8`, имитируемую на различных серверах с помощью **Xray-core** и **обратного туннелирования**.
|
||||
Это руководство описывает, как развернуть распределенную частную сеть между удаленными серверами, используя единое адресное пространство `10.0.0.0/8`, имитируемое с помощью **Xray-core**, **TPROXY** и **обратного VLESS-туннелирования**.
|
||||
|
||||
---
|
||||
|
||||
## Технический разбор и архитектура
|
||||
|
||||
### Концепция виртуальной подсети
|
||||
|
||||
Мы создаем «плоскую сеть» (Layer 3) поверх публичного интернета. Каждый узел выступает в качестве полномочного шлюза для своего собственного сегмента `/16` из диапазона `10.0.0.0/8`. Это обеспечивает бесперебойную связь между серверами, как если бы они находились в одной локальной сети.
|
||||
Мы создаем «плоскую сеть» третьего уровня (Layer 3) поверх публичного интернета. Каждый серверный узел выступает полномочным шлюзом для своего собственного отрезка `/16` из приватного пространства `10.0.0.0/8`:
|
||||
|
||||
- **Сервер A (Hub / Portal)**: Владеет `10.1.0.0/16`
|
||||
- **Сервер B (Spoke / Bridge)**: Владеет `10.2.0.0/16`
|
||||
- **Сервер C (Spoke / Bridge)**: Владеет `10.3.0.0/16`
|
||||
|
||||
Это обеспечивает прозрачное взаимодействие серверов и Docker-контейнеров, как если бы они находились в одной локальной физической сети.
|
||||
|
||||
### Наука перехвата (TPROXY)
|
||||
|
||||
В отличие от `REDIRECT`, который работает только для TCP и изменяет IP-адрес назначения, **TPROXY** (Transparent Proxy) позволяет Xray перехватывать как TCP, так и UDP, сохраняя при этом исходные заголовки пакетов.
|
||||
В отличие от `REDIRECT`, который поддерживает только TCP и подменяет IP-адрес назначения на адрес прокси, **TPROXY** (Transparent Proxy) перехватывает как TCP-, так и UDP-пакеты, сохраняя исходные IP-заголовки отправителя и получателя.
|
||||
|
||||
- **Механизм**: Когда пакет, предназначенный для `10.x.x.x`, попадает в цепочку `PREROUTING` или `OUTPUT`, таблица Linux Mangle помечает его меткой `0x1`.
|
||||
- **Сохранение**: Входящий поток `tunnel` в Xray использует опцию сокета `tproxy` для чтения исходного IP-адреса назначения из метаданных сокета, что позволяет правильно маршрутизировать пакет.
|
||||
- **Маркировка**: Когда пакет, предназначенный для `10.x.x.x`, попадает в цепочку `PREROUTING` или `OUTPUT` брандмауэра iptables, таблица Mangle помечает его меткой `0x1`.
|
||||
- **Сохранение**: Входящий поток `tunnel` в Xray использует опцию сокета `"sockopt": { "tproxy": "tproxy" }`, позволяя извлекать оригинальный IP-адрес назначения из метаданных сокета и корректно маршрутизировать пакет.
|
||||
|
||||
### Политика маршрутизации (Таблица 100)
|
||||
|
||||
Хотя метки брандмауэра идентифицируют перехваченные пакеты, они сами по себе не изменяют путь маршрутизации. Чтобы перенаправить этот трафик в Xray, мы используем **Policy Routing**:
|
||||
Метки брандмауэра сами по себе не меняют маршрут следования пакета. Чтобы направить трафик в Xray, применяется **Policy Routing**:
|
||||
|
||||
1. **Правило (`ip rule`)**: `ip rule add fwmark 1 lookup 100` предписывает сетевому стеку использовать пользовательскую таблицу маршрутизации для помеченных пакетов.
|
||||
2. **Таблица (`ip route`)**: Таблица 100 настроена с параметром `local default dev lo`, что заставляет систему рассматривать все IP-адреса назначения как локальные и направлять их на интерфейс петли (loopback).
|
||||
3. **Передача**: Xray, прослушивающий `127.0.0.1:12345` с опцией `tproxy`, перехватывает пакет с интерфейса loopback.
|
||||
4. **Предотвращение петель**: Мы используем `mark: 255` в исходящих потоках Xray, чтобы исключить его собственный трафик из перехвата, предотвращая бесконечные циклы маршрутизации.
|
||||
1. **Правило (`ip rule`)**: `ip rule add fwmark 0x1 lookup 100 priority 100` предписывает ядру Linux использовать специальную таблицу маршрутизации 100 для пакетов с меткой `0x1`.
|
||||
2. **Таблица (`ip route`)**: Таблица 100 содержит правило `ip route add local 0.0.0.0/0 dev lo table 100`, направляющее все помеченные пакеты на виртуальный интерфейс loopback (`dev lo`).
|
||||
3. **Перехват**: Xray прослушивает порт `127.0.0.1:12345` с включенным TPROXY и подхватывает пакет с loopback.
|
||||
4. **Защита от петель**: Для исходящего трафика самого Xray задается метка `"sockopt": { "mark": 255 }` (`0xff`), что исключает его из правил перехвата и предотвращает бесконечную рекурсию маршрутизации.
|
||||
|
||||
### Механизм обратного прокси
|
||||
---
|
||||
|
||||
Для доступа к серверам за NAT (Spokes) из центрального хаба Xray использует сложную архитектуру обратного прокси:
|
||||
## 1. Подготовительный этап и генерация ключей
|
||||
|
||||
- **Bridge (Spoke)**: Инициирует соединение с порталом. Он действует как клиент для туннеля, но функционально ведет себя как сервер после установления соединения.
|
||||
- **Portal (Hub)**: Прослушивает входящие соединения от Bridge и использует «фиктивные домены» (Dummy Domains) для их идентификации, ретранслируя трафик через установленные туннели.
|
||||
- **Мультиплексирование (Mux)**: Использует [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) для упаковки сотен виртуальных потоков в одно физическое TLS-соединение, что снижает накладные расходы и позволяет обходить тайм-ауты NAT.
|
||||
Сгенерируйте 4 уникальных UUID для защиты обратных туннелей VLESS между Хабом и Узлами:
|
||||
|
||||
## Руководство по настройке
|
||||
- `UUID_B_REVERSE`: Служебный туннель управления для Сервера B (Bridge)
|
||||
- `UUID_B_DIRECT`: Передача данных для Сервера B
|
||||
- `UUID_C_REVERSE`: Служебный туннель управления для Сервера C (Bridge)
|
||||
- `UUID_C_DIRECT`: Передача данных для Сервера C
|
||||
|
||||
### Этап 0: Предварительные требования (только для сервера А)
|
||||
---
|
||||
|
||||
Серверу А (Hub) требуется действующий SSL-сертификат для XTLS-Vision. Направьте ваш домен (`A-DOMAIN`) на публичный IP сервера А и убедитесь, что сертификаты доступны (например, через Certbot) по адресу `/etc/letsencrypt/live/A-DOMAIN/`.
|
||||
## 2. Сетевая настройка хоста и TPROXY (routing.sh и before.rules)
|
||||
|
||||
### Этап 1: Безопасность и идентификация
|
||||
До запуска контейнеров Xray на каждом узле необходимо активировать правила маршрутизации ядра и цепочки `mangle` в UFW.
|
||||
|
||||
Для каждого соединения требуется уникальный UUID. Сгенерируйте 4 UUID для сопоставления следующих соединений:
|
||||
### A. Инициализация маршрутизации ядра (routing.sh)
|
||||
|
||||
- `UUID_B_REVERSE`: Управление туннелем для сервера B.
|
||||
- `UUID_B_DIRECT`: Трафик данных для сервера B.
|
||||
- `UUID_C_REVERSE`: Управление туннелем для сервера C.
|
||||
- `UUID_C_DIRECT`: Трафик данных для сервера C.
|
||||
Скрипт [`routing.sh`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/routing.sh) включает пересылку IPv4-пакетов (ip_forward), связывает метку `0x1` с таблицей 100 и перенаправляет трафик таблицы 100 на интерфейс loopback (`dev lo`).
|
||||
|
||||
### Этап 2: Настройка хоста
|
||||
Запустите скрипт на **всех** серверах:
|
||||
|
||||
На **КАЖДОМ** узле сервера выполните следующее:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
1. **Инициализация маршрутизации**: Запустите скрипт настройки маршрутизации для конфигурации параметров ядра и таблицы 100.
|
||||
2. **Настройка брандмауэра**: Обновите `/etc/ufw/before.rules`. Вставьте специфические правила узла сразу после заголовка `*filter`. Эти правила обрабатывают логику таблицы `mangle`, необходимую для TPROXY.
|
||||
3. **Перезагрузка**: Примените изменения с помощью `sudo ufw reload`.
|
||||
echo "Applying routing configuration..."
|
||||
|
||||
### Этап 3: Развертывание
|
||||
# 1. Enable IP Forwarding
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
Обновите конфигурации `xray.jsonc`, указав сгенерированные UUID, ваш домен и публичные IP-адреса. Запустите стек с помощью `docker compose up -d`. На сервере А установка compose также инициализирует внутреннюю мостовую сеть для таких сервисов, как AdGuard и Nginx.
|
||||
# 2. IP Rule for mark 0x1 -> table 100
|
||||
if sudo ip rule show | grep -q 'fwmark 0x1 lookup 100'; then
|
||||
echo "IP rule for mark 0x1 already exists."
|
||||
else
|
||||
sudo ip rule add fwmark 0x1 lookup 100 priority 100
|
||||
echo "IP rule added for mark 0x1."
|
||||
fi
|
||||
|
||||
## Примеры конфигурации
|
||||
# 3. Local route in table 100
|
||||
if sudo ip route show table 100 2>/dev/null | grep -q 'local default dev lo'; then
|
||||
echo "Local route in table 100 already exists."
|
||||
else
|
||||
sudo ip route add local 0.0.0.0/0 dev lo table 100
|
||||
echo "Local route added to table 100."
|
||||
fi
|
||||
|
||||
### Сервер A: Хаб (10.1.0.0/16)
|
||||
echo "Routing configuration applied successfully."
|
||||
```
|
||||
|
||||
Хаб обрабатывает входы обратных туннелей и маршрутизирует трафик в соответствующий Spoke на основе диапазона 10.x.x.x.
|
||||
Полная конфигурация доступна здесь: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
### B. Конфигурация брандмауэра Хаба (a/before.rules)
|
||||
|
||||
На **Сервере A (Hub)** добавьте правила TPROXY в конец файла `/etc/ufw/before.rules` (после первой директивы `COMMIT`).
|
||||
|
||||
Логика правил:
|
||||
1. **Исключение локальной подсети**: Пакеты для `10.1.0.0/16` обрабатываются стандартно.
|
||||
2. **Исключение внешнего трафика**: Трафик вне `10.0.0.0/8` не затрагивается.
|
||||
3. **Исключение метки Xray (`0xff`)**: Собственный трафик Xray игнорируется для предотвращения зацикливания.
|
||||
4. **TPROXY-перехват**: Запросы TCP и UDP к `10.0.0.0/8` направляются в Xray на `127.0.0.1:12345` с меткой `1`.
|
||||
|
||||
Файл в репозитории: [`a/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/before.rules)
|
||||
|
||||
```ini
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
# --- PREROUTING CHAIN (Трафик от контейнеров и других узлов) ---
|
||||
-A XRAY -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
# --- OUTPUT CHAIN (Трафик, создаваемый сам хостом) ---
|
||||
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
# --- Оптимизация DIVERT ---
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
|
||||
*filter
|
||||
-A ufw-before-forward -s 10.1.0.0/16 -d 10.1.0.0/16 -j ACCEPT
|
||||
-A ufw-before-forward -s 10.1.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
-A ufw-before-input -s 10.1.0.0/16 -d 10.1.0.0/16 -j ACCEPT
|
||||
-A ufw-before-input -s 10.1.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
```
|
||||
|
||||
### C. Конфигурация брандмауэра узлов Spoke (b/before.rules и c/before.rules)
|
||||
|
||||
На **Сервере B (Spoke)** и **Сервере C (Spoke)** примените соответствующие `/etc/ufw/before.rules`.
|
||||
|
||||
Для Сервера B (`10.2.0.0/16`) локальный трафик внутри подсети `10.2.0.0/16` исключается (`-d 10.2.0.0/16 -j RETURN`), а любые пакеты к остальной части `10.0.0.0/8` (например, к `10.1.x.x` или `10.3.x.x`) перехватываются TPROXY и уходят через обратный VLESS-туннель на Сервер А.
|
||||
|
||||
Файлы в репозитории: [`b/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/before.rules) и [`c/before.rules`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/c/before.rules)
|
||||
|
||||
```ini
|
||||
*mangle
|
||||
:XRAY - [0:0]
|
||||
:XRAY_MASK - [0:0]
|
||||
:DIVERT - [0:0]
|
||||
|
||||
-A XRAY -d 10.2.0.0/16 -j RETURN
|
||||
-A XRAY ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY -m mark --mark 0xff -j RETURN
|
||||
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
|
||||
-A PREROUTING -j XRAY
|
||||
|
||||
-A XRAY_MASK -d 10.2.0.0/16 -j RETURN
|
||||
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
|
||||
-A XRAY_MASK -m mark --mark 0xff -j RETURN
|
||||
-A XRAY_MASK -p udp -j MARK --set-mark 1
|
||||
-A XRAY_MASK -p tcp -j MARK --set-mark 1
|
||||
-A OUTPUT -j XRAY_MASK
|
||||
|
||||
-A DIVERT -j MARK --set-mark 1
|
||||
-A DIVERT -j ACCEPT
|
||||
-I PREROUTING -p tcp -m socket -j DIVERT
|
||||
|
||||
COMMIT
|
||||
|
||||
*filter
|
||||
-A ufw-before-forward -s 10.2.0.0/16 -d 10.2.0.0/16 -j ACCEPT
|
||||
-A ufw-before-forward -s 10.2.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
-A ufw-before-input -s 10.2.0.0/16 -d 10.2.0.0/16 -j ACCEPT
|
||||
-A ufw-before-input -s 10.2.0.0/16 -d 10.0.0.0/8 -m mark --mark 0x1 -j ACCEPT
|
||||
```
|
||||
|
||||
Перезагрузите правила брандмауэра на каждом узле командой `sudo ufw reload`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация узлов Xray-core (xray.jsonc)
|
||||
|
||||
### A. Конфигурация Хаба (a/xray.jsonc)
|
||||
|
||||
Хаб прослушивает публичный порт 443 по протоколам VLESS и XTLS-Vision. Он сопоставляет входящие соединения обратных туннелей (`reverse-b`, `reverse-c`) со свойствами маршрутизации для подсетей `/16`.
|
||||
|
||||
Файл в репозитории: [`a/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -83,31 +201,40 @@ tags: ['networking', 'xray', 'guide']
|
||||
"tag": "proxy-direct",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
|
||||
// ... повторите для других Spokes
|
||||
],
|
||||
},
|
||||
{ "id": "UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
|
||||
{ "id": "UUID_B_DIRECT" },
|
||||
{ "id": "UUID_C_REVERSE", "reverse": { "tag": "reverse-c" } },
|
||||
{ "id": "UUID_C_DIRECT" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "proxy-enclave",
|
||||
"port": 12345,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "tunnel",
|
||||
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
|
||||
},
|
||||
"tag": "proxy-enclave",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"tproxy": "tproxy",
|
||||
"mark": 255
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
|
||||
],
|
||||
},
|
||||
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Сервер B: Узел Spoke (10.2.0.0/16)
|
||||
### B. Конфигурация Spoke (b/xray.jsonc)
|
||||
|
||||
Spoke инициирует обратное соединение с Хабом и обрабатывает выход в локальную подсеть.
|
||||
Полная конфигурация доступна здесь: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
|
||||
Узел Spoke устанавливает клиенские соединения VLESS к Серверу A (`A-EXTERNAL-IP`), поддерживая постоянный обратный туннель для входящих запросов и передавая исходящий трафик `10.0.0.0/8`.
|
||||
|
||||
Файлы в репозитории: [`b/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc) и [`c/xray.jsonc`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/c/xray.jsonc)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -117,37 +244,39 @@ Spoke инициирует обратное соединение с Хабом
|
||||
"tag": "forward-a-reverse",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" },
|
||||
},
|
||||
"id": "UUID_B_REVERSE",
|
||||
"reverse": { "tag": "reverse-b" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "vless",
|
||||
"tag": "forward-a",
|
||||
"settings": {
|
||||
"address": "A-EXTERNAL-IP",
|
||||
"id": "REPLACE_WITH_UUID_B_DIRECT",
|
||||
},
|
||||
},
|
||||
"id": "UUID_B_DIRECT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
|
||||
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
|
||||
],
|
||||
},
|
||||
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ресурсы и ссылки
|
||||
|
||||
Для получения полного автоматизированного примера этой архитектуры посетите следующий репозиторий:
|
||||
Полный пример репозитория с конфигурациями Docker Compose ([`a/docker-compose.yaml`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/docker-compose.yaml) и [`b/docker-compose.yaml`](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/docker-compose.yaml)):
|
||||
|
||||
- [xray-core-subnet-mimicry на GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
- [Репозиторий xray-core-subnet-mimicry на GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
|
||||
|
||||
Дополнительные технические материалы:
|
||||
Техническая документация:
|
||||
|
||||
- [Официальная документация Xray-core](https://xtls.github.io/)
|
||||
- [Понимание TPROXY (блог Cloudflare)](https://blog.cloudflare.com/how-we-built-spectrum/)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import * as TestArticle from './articles/2026/test-article.en.mdx';
|
||||
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.en.mdx';
|
||||
import * as XrayCoreSubnetMimicry from './articles/2026/xray-core-subnet-mimicry.en.mdx';
|
||||
import * as JoplinWebDeploymentGuide from './articles/2026/joplin-web-deployment-guide.en.mdx';
|
||||
@@ -42,7 +41,6 @@ function register(module: unknown, slug: string): Article {
|
||||
}
|
||||
|
||||
export const articles: Record<string, Article> = {
|
||||
'test-article': register(TestArticle, '2026/test-article'),
|
||||
'matrix-server-deployment-guide': register(
|
||||
MatrixServerDeploymentGuide,
|
||||
'2026/matrix-server-deployment-guide'
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ermnvldmr/i18n": "workspace:*",
|
||||
"@ermnvldmr/kits": "workspace:*",
|
||||
"@ermnvldmr/ssg": "workspace:*",
|
||||
"@ermnvldmr/stl": "workspace:*",
|
||||
"@ermnvldmr/ui": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/kits';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { articles } from '../../../../content/registry';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/kits';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { articles } from '../../../../content/registry';
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { articles } from '../../../../content/registry';
|
||||
import { ArticleLayout } from '../../../components/Layout/ArticleLayout';
|
||||
|
||||
const article = articles['test-article'];
|
||||
|
||||
/**
|
||||
* Test article page component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* createPage(TestArticlePage, { title: '...', description: '...' });
|
||||
* ```
|
||||
*/
|
||||
function TestArticlePage(): React.JSX.Element {
|
||||
return (
|
||||
<ArticleLayout
|
||||
createdDate={article.createdDate}
|
||||
currentPath={localePath('/articles/2026/test-article')}
|
||||
description={article.description}
|
||||
tags={article.tags}
|
||||
title={article.title}
|
||||
updatedDate={article.updatedDate}
|
||||
>
|
||||
<article.Component components={MARKDOWN_COMPONENTS} />
|
||||
</ArticleLayout>
|
||||
);
|
||||
}
|
||||
|
||||
createPage(TestArticlePage, {
|
||||
title: `${article.title} - Vladimir Eremin`,
|
||||
description: article.description,
|
||||
});
|
||||
export default TestArticlePage;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/kits';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { articles } from '../../../../content/registry';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LOCALE, localePath } from '@ermnvldmr/i18n';
|
||||
import { BentoGrid, InfoCard, Time } from '@ermnvldmr/kits';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { BentoGrid, Breadcrumbs, HStack, Separator, Text, Time, VStack } from '@ermnvldmr/ui';
|
||||
import { Breadcrumbs, HStack, Separator, Text, VStack } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './index.i18n';
|
||||
@@ -37,11 +38,16 @@ function ArticlesList(): React.JSX.Element {
|
||||
>
|
||||
<BentoGrid>
|
||||
{sortedArticles.map((article, index) => (
|
||||
<BentoGrid.InfoCard
|
||||
<InfoCard
|
||||
key={article.slug}
|
||||
colSpan={index === 0 ? 2 : 1}
|
||||
description={article.description}
|
||||
footer={
|
||||
bg={index === 0 ? undefined : 'transparent'}
|
||||
colSpan={index === 0 ? 4 : 2}
|
||||
href={localePath(`/articles/${article.slug}`)}
|
||||
variant={index === 0 ? 'primary' : 'default'}
|
||||
>
|
||||
<InfoCard.Header>{article.title}</InfoCard.Header>
|
||||
<InfoCard.Body>{article.description}</InfoCard.Body>
|
||||
<InfoCard.Footer>
|
||||
<VStack align="stretch" gap={2}>
|
||||
<Separator thinned="thinned-end" type="single" />
|
||||
<HStack align="center" gap={4} justify="between" wrap="wrap">
|
||||
@@ -59,11 +65,8 @@ function ArticlesList(): React.JSX.Element {
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
}
|
||||
href={localePath(`/articles/${article.slug}`)}
|
||||
rowSpan={index === 0 ? 2 : 1}
|
||||
title={article.title}
|
||||
/>
|
||||
</InfoCard.Footer>
|
||||
</InfoCard>
|
||||
))}
|
||||
</BentoGrid>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -62,6 +62,14 @@ export const { t } = createKeyset({
|
||||
en: 'Genshin Impact Pop! Aether (#160)',
|
||||
ru: 'Genshin Impact Pop! Aether (#160)',
|
||||
},
|
||||
'Taito - Lycoris Recoil Desktop Cute Figure - Takina Inoue (Roomwear Ver.)': {
|
||||
en: 'Taito - Lycoris Recoil Desktop Cute Figure - Takina Inoue (Roomwear Ver.)',
|
||||
ru: 'Taito - Lycoris Recoil Desktop Cute Figure - Takina Inoue (Roomwear Ver.)',
|
||||
},
|
||||
'Taito - Lycoris Recoil Aqua Desktop Cute Figure - Chisato Nishikigi (Roomwear Ver.)': {
|
||||
en: 'Taito - Lycoris Recoil Aqua Desktop Cute Figure - Chisato Nishikigi (Roomwear Ver.)',
|
||||
ru: 'Taito - Lycoris Recoil Aqua Desktop Cute Figure - Chisato Nishikigi (Roomwear Ver.)',
|
||||
},
|
||||
'LEGO Sets': {
|
||||
en: 'LEGO Sets',
|
||||
ru: 'Наборы LEGO',
|
||||
@@ -95,6 +103,14 @@ export const { t } = createKeyset({
|
||||
en: 'The Pig House (21170)',
|
||||
ru: 'The Pig House (21170)',
|
||||
},
|
||||
'NASA Artemis Space Launch System (10341)': {
|
||||
en: 'NASA Artemis Space Launch System (10341)',
|
||||
ru: 'NASA Artemis Space Launch System (10341)',
|
||||
},
|
||||
'Himeji Castle (21060)': {
|
||||
en: 'Himeji Castle (21060)',
|
||||
ru: 'Замок Химэдзи (21060)',
|
||||
},
|
||||
'Die-cast Models': {
|
||||
en: 'Die-cast Models',
|
||||
ru: 'Дайкаст модели',
|
||||
@@ -145,18 +161,22 @@ export const { t } = createKeyset({
|
||||
en: 'Freshly roasted specialty coffee beans',
|
||||
ru: 'Свежеобжаренные спешелти зерна',
|
||||
},
|
||||
'Espresso Machine': {
|
||||
en: 'Espresso Machine',
|
||||
ru: 'Эспрессо-машина',
|
||||
"De'longhi Dedica Arte EC885": {
|
||||
en: "De'longhi Dedica Arte EC885",
|
||||
ru: "Кофемашина De'longhi Dedica Arte ec885",
|
||||
},
|
||||
'Kingrinder K6': {
|
||||
en: 'Kingrinder K6',
|
||||
ru: 'Кофемолка Kingrinder K6',
|
||||
},
|
||||
'Timemore Black Mirror Basic 2': {
|
||||
en: 'Timemore Black Mirror Basic 2',
|
||||
ru: 'Весы Timemore Black Mirror Basic 2 (хорошие весы обязательно нужны для кофеделания)',
|
||||
},
|
||||
'Hario V60': {
|
||||
en: 'Hario V60',
|
||||
ru: 'Hario V60',
|
||||
},
|
||||
'Manual Hand Grinder': {
|
||||
en: 'Manual Hand Grinder',
|
||||
ru: 'Ручная кофемолка',
|
||||
},
|
||||
'French Press': {
|
||||
en: 'French Press',
|
||||
ru: 'Френч-пресс',
|
||||
@@ -169,9 +189,9 @@ export const { t } = createKeyset({
|
||||
en: 'Various tools and equipment for electronics and other side projects.',
|
||||
ru: 'Различные инструменты и оборудование для электроники и других проектов.',
|
||||
},
|
||||
'Alientek T90 Soldering Iron (or a similar compact, smart iron)': {
|
||||
en: 'Alientek T90 Soldering Iron (or a similar compact, smart iron)',
|
||||
ru: 'Паяльник Alientek T90 (или аналогичный компактный умный паяльник)',
|
||||
'Alientek T90B Soldering Iron': {
|
||||
en: 'Alientek T90B Soldering Iron',
|
||||
ru: 'Паяльник Alientek T90B',
|
||||
},
|
||||
'Other items': {
|
||||
en: 'Other items',
|
||||
@@ -197,4 +217,8 @@ export const { t } = createKeyset({
|
||||
en: 'Cast Iron Cube 10x10x10cm (just for the tactile density)',
|
||||
ru: 'Чугунный куб 10x10x10 см (по приколу)',
|
||||
},
|
||||
'Good kitchen utensils (knives, pans, and other cooking gear)': {
|
||||
en: 'Good kitchen utensils (knives, pans, and other cooking gear)',
|
||||
ru: 'Хорошая кухонная утварь (ножи, сковороды и прочее для готовки)',
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { BentoGrid, InfoCard } from '@ermnvldmr/kits';
|
||||
import { createPage } from '@ermnvldmr/ssg';
|
||||
import { BentoGrid, Header, Paragraph, VStack, Breadcrumbs } from '@ermnvldmr/ui';
|
||||
import { Ghost, Coffee, Plane, Check, Gift, Cuboid, BoxSelect, Cpu } from 'lucide-react';
|
||||
import { Breadcrumbs, Header, Paragraph, VStack } from '@ermnvldmr/ui';
|
||||
import { BoxSelect, Check, Coffee, Cpu, Cuboid, Gift, Ghost, Plane } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './index.i18n';
|
||||
import { DefaultLayout } from '../../components/Layout/DefaultLayout';
|
||||
@@ -57,165 +59,188 @@ function WantedPage(): React.JSX.Element {
|
||||
|
||||
<BentoGrid>
|
||||
{/* Collectibles - Figurines */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t(
|
||||
'The specifics of the edition and its authenticity are quite important here. It is usually best to know the exact details to get it right.'
|
||||
)}
|
||||
icon={<Ghost size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('NENDOROID 1970 Jill Stingray (VA-11 Hall-A)'),
|
||||
t('Mirror’s Edge Catalyst (Collector’s Edition)'),
|
||||
t("Deus Ex: Mankind Divided (Collector's Edition)"),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [t('Genshin Impact Pop! Aether (#160)')],
|
||||
},
|
||||
]}
|
||||
title={t('Figurines')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<Ghost size={24} />}>{t('Figurines')}</InfoCard.Header>
|
||||
<InfoCard.Body>
|
||||
{t(
|
||||
'The specifics of the edition and its authenticity are quite important here. It is usually best to know the exact details to get it right.'
|
||||
)}
|
||||
</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['wanted']}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('NENDOROID 1970 Jill Stingray (VA-11 Hall-A)'),
|
||||
t('Mirror’s Edge Catalyst (Collector’s Edition)'),
|
||||
t("Deus Ex: Mankind Divided (Collector's Edition)"),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [
|
||||
t('Genshin Impact Pop! Aether (#160)'),
|
||||
t('Taito - Lycoris Recoil Desktop Cute Figure - Takina Inoue (Roomwear Ver.)'),
|
||||
t(
|
||||
'Taito - Lycoris Recoil Aqua Desktop Cute Figure - Chisato Nishikigi (Roomwear Ver.)'
|
||||
),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
{/* Collectibles - LEGO */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t(
|
||||
'I appreciate sets that focus on engineering and make for good display pieces, like Architecture, Technic, or the Botanical collection. I generally lean away from traditional play-sets.'
|
||||
)}
|
||||
icon={<Cuboid size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('Architectural landmarks or skylines'),
|
||||
t('NASA and space exploration sets (like the #92176 Ideas NASA Apollo Saturn V)'),
|
||||
t('High-fidelity Technic vehicles'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [t('Japanese Red Maple Bonsai (10348)'), t('The Pig House (21170)')],
|
||||
},
|
||||
]}
|
||||
title={t('LEGO Sets')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<Cuboid size={24} />}>{t('LEGO Sets')}</InfoCard.Header>
|
||||
<InfoCard.Body>
|
||||
{t(
|
||||
'I appreciate sets that focus on engineering and make for good display pieces, like Architecture, Technic, or the Botanical collection. I generally lean away from traditional play-sets.'
|
||||
)}
|
||||
</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['wanted']}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('Architectural landmarks or skylines'),
|
||||
t('NASA and space exploration sets (like the #92176 Ideas NASA Apollo Saturn V)'),
|
||||
t('High-fidelity Technic vehicles'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [
|
||||
t('Japanese Red Maple Bonsai (10348)'),
|
||||
t('The Pig House (21170)'),
|
||||
t('NASA Artemis Space Launch System (10341)'),
|
||||
t('Himeji Castle (21060)'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
{/* Collectibles - Die-cast */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t(
|
||||
'I enjoy high-quality pre-assembled die-cast models. I am not much into plastic kits that require glue or paint to assemble.'
|
||||
)}
|
||||
icon={<Plane size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('Spacecraft scale replicas (SpaceX, NASA)'),
|
||||
t('Motorsport cars (Modern F1, GT3, Endurance Legends)'),
|
||||
t('Aviation models (SR-71 Blackbird, Concorde)'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [t('Bburago – McLaren F1 MCL39 2025 #4 Norris')],
|
||||
},
|
||||
]}
|
||||
title={t('Die-cast Models')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<Plane size={24} />}>{t('Die-cast Models')}</InfoCard.Header>
|
||||
<InfoCard.Body>
|
||||
{t(
|
||||
'I enjoy high-quality pre-assembled die-cast models. I am not much into plastic kits that require glue or paint to assemble.'
|
||||
)}
|
||||
</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['wanted']}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('Spacecraft scale replicas (SpaceX, NASA)'),
|
||||
t('Motorsport cars (Modern F1, GT3, Endurance Legends)'),
|
||||
t('Aviation models (SR-71 Blackbird, Concorde)'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [t('Bburago – McLaren F1 MCL39 2025 #4 Norris')],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
{/* Coffee stuff */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t(
|
||||
'My current espresso setup uses a 51mm portafilter, so any accessories for it need to match that size perfectly.'
|
||||
)}
|
||||
icon={<Coffee size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('AeroPress'),
|
||||
t('Dedicated cold brewer (Hario or Toddy)'),
|
||||
t('51mm WDT Tool'),
|
||||
t('Freshly roasted specialty coffee beans'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [
|
||||
t('Espresso Machine'),
|
||||
t('Hario V60'),
|
||||
t('Manual Hand Grinder'),
|
||||
t('French Press'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
title={t('Coffee gear')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<Coffee size={24} />}>{t('Coffee gear')}</InfoCard.Header>
|
||||
<InfoCard.Body>
|
||||
{t(
|
||||
'My current espresso setup uses a 51mm portafilter, so any accessories for it need to match that size perfectly.'
|
||||
)}
|
||||
</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['wanted']}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('AeroPress'),
|
||||
t('Dedicated cold brewer (Hario or Toddy)'),
|
||||
t('51mm WDT Tool'),
|
||||
t('Freshly roasted specialty coffee beans'),
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [
|
||||
t("De'longhi Dedica Arte EC885"),
|
||||
t('Kingrinder K6'),
|
||||
t('Timemore Black Mirror Basic 2'),
|
||||
t('Hario V60'),
|
||||
t('French Press'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
{/* Hobby */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t('Various tools and equipment for electronics and other side projects.')}
|
||||
icon={<Cpu size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [t('Alientek T90 Soldering Iron (or a similar compact, smart iron)')],
|
||||
},
|
||||
]}
|
||||
title={t('Hobby')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<Cpu size={24} />}>{t('Hobby')}</InfoCard.Header>
|
||||
<InfoCard.Body>
|
||||
{t('Various tools and equipment for electronics and other side projects.')}
|
||||
</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['owned']}
|
||||
sections={[
|
||||
{
|
||||
value: 'owned',
|
||||
label: t('Already Owned'),
|
||||
labelIcon: <Check size={18} />,
|
||||
items: [t('Alientek T90B Soldering Iron')],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
{/* Other stuff */}
|
||||
<BentoGrid.ListCard
|
||||
colSpan={2}
|
||||
defaultValue={['wanted']}
|
||||
description={t('A few standalone pieces that caught my eye.')}
|
||||
icon={<BoxSelect size={24} />}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('TimePiece® 3D tear-off calendar'),
|
||||
t('Seiko SSC813 Prospex watch'),
|
||||
t('Tag Heuer Carrera watch'),
|
||||
t('Cast Iron Cube 10x10x10cm (just for the tactile density)'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
title={t('Other items')}
|
||||
/>
|
||||
<InfoCard bg="transparent" colSpan={2}>
|
||||
<InfoCard.Header icon={<BoxSelect size={24} />}>{t('Other items')}</InfoCard.Header>
|
||||
<InfoCard.Body>{t('A few standalone pieces that caught my eye.')}</InfoCard.Body>
|
||||
<InfoCard.List
|
||||
defaultValue={['wanted']}
|
||||
sections={[
|
||||
{
|
||||
value: 'wanted',
|
||||
label: t('Looking for'),
|
||||
labelIcon: <Gift size={18} />,
|
||||
items: [
|
||||
t('TimePiece® 3D tear-off calendar'),
|
||||
t('Seiko SSC813 Prospex watch'),
|
||||
t('Tag Heuer Carrera watch'),
|
||||
t('Cast Iron Cube 10x10x10cm (just for the tactile density)'),
|
||||
t('Good kitchen utensils (knives, pans, and other cooking gear)'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InfoCard>
|
||||
</BentoGrid>
|
||||
</VStack>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Container, HStack, Link, ThemeSwitch, VStack, Text } from '@ermnvldmr/ui';
|
||||
import { ThemeSwitch } from '@ermnvldmr/kits';
|
||||
import { Container, HStack, Icon, Link, Text, VStack } from '@ermnvldmr/ui';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch';
|
||||
@@ -44,7 +46,11 @@ export const Footer = ({ currentPath }: FooterProps) => {
|
||||
</Link>
|
||||
</VStack>
|
||||
<VStack align="end" gap={4}>
|
||||
<ThemeSwitch />
|
||||
<ThemeSwitch
|
||||
darkIcon={<Icon icon={Moon} />}
|
||||
lightIcon={<Icon icon={Sun} />}
|
||||
systemIcon={<Icon icon={Monitor} />}
|
||||
/>
|
||||
<LocaleSwitch currentPath={currentPath} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ArticleLayout } from './ArticleLayout';
|
||||
|
||||
describe('ArticleLayout', () => {
|
||||
const defaultProps = {
|
||||
title: 'Test Article Title',
|
||||
createdDate: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
it('renders title, created date, and content', () => {
|
||||
render(
|
||||
<ArticleLayout {...defaultProps}>
|
||||
Article content body
|
||||
</ArticleLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Article content body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders updated date when updatedDate prop is provided', () => {
|
||||
const updatedDate = new Date('2026-02-01T00:00:00Z');
|
||||
render(
|
||||
<ArticleLayout {...defaultProps} updatedDate={updatedDate}>
|
||||
Modified article content
|
||||
</ArticleLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Updated/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tags when provided', () => {
|
||||
render(
|
||||
<ArticleLayout {...defaultProps} tags={['react', 'typescript']}>
|
||||
Article content
|
||||
</ArticleLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText('react')).toBeInTheDocument();
|
||||
expect(screen.getByText('typescript')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { Markdown, Time, Text, PageColumns, PageColumn, Breadcrumbs, HStack } from '@ermnvldmr/ui';
|
||||
import { Markdown, PageColumn, PageColumns, Time } from '@ermnvldmr/kits';
|
||||
import { Breadcrumbs, HStack, Text } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './ArticleLayout.i18n';
|
||||
@@ -50,11 +51,11 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const displayDate = updatedDate ? (
|
||||
<Text color="muted" size="s" type="label">
|
||||
<Text color="muted" size="l">
|
||||
{t('Updated')} <Time date={updatedDate} />
|
||||
</Text>
|
||||
) : (
|
||||
<Time color="muted" date={createdDate} size="s" type="label" />
|
||||
<Time color="muted" date={createdDate} size="l" />
|
||||
);
|
||||
|
||||
const metadata = (
|
||||
@@ -63,7 +64,7 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
|
||||
{tags && tags.length > 0 && (
|
||||
<HStack gap={1} wrap="wrap">
|
||||
{tags.map((tag) => (
|
||||
<Text key={tag} className="before:content-['#']" color="muted" size="s">
|
||||
<Text key={tag} className="before:content-['#']" color="muted" size="l">
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Header, Paragraph, VStack, PageColumns, PageColumn } from '@ermnvldmr/ui';
|
||||
import { PageColumn, PageColumns } from '@ermnvldmr/kits';
|
||||
import { Header, Paragraph, VStack } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { DefaultLayout } from './DefaultLayout';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageContainer, type PageWidth, type PagePadding } from '@ermnvldmr/ui';
|
||||
import { PageContainer, type PagePadding, type PageWidth } from '@ermnvldmr/kits';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '../../Footer';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageRoot } from '@ermnvldmr/ui';
|
||||
import { PageRoot } from '@ermnvldmr/kits';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageContainer, type PageWidth, type PagePadding } from '@ermnvldmr/ui';
|
||||
import { PageContainer, type PagePadding, type PageWidth } from '@ermnvldmr/kits';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '../../Footer';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { PageHead as PageHeadOriginal, HStack, Link } from '@ermnvldmr/ui';
|
||||
import { PageHead as PageHeadOriginal } from '@ermnvldmr/kits';
|
||||
import { HStack, Link } from '@ermnvldmr/ui';
|
||||
import React from 'react';
|
||||
|
||||
import { t } from './PageHead.i18n';
|
||||
|
||||
import type { PageHeadStrategy } from '@ermnvldmr/ui';
|
||||
import type { PageHeadStrategy } from '@ermnvldmr/kits';
|
||||
|
||||
/**
|
||||
* Props for the WWW-specific PageHead.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
/**
|
||||
* A separator that uses a rainbow gradient.
|
||||
* Glances at the bottom of the viewport to invite scrolling.
|
||||
* A separator that uses a rainbow gradient with a soft, dimmed upward ambient glow.
|
||||
* Glances at the bottom of the viewport to invite scrolling and visually bridge
|
||||
* the main page content and footer, placed behind page content (-z-10).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -12,9 +12,14 @@ import React from 'react';
|
||||
*/
|
||||
export const RainbowSeparator = () => {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-[4px] bg-gradient-to-r from-red-500 via-yellow-500 via-green-500 via-blue-500 to-purple-500 animate-pulse opacity-70"
|
||||
role="separator"
|
||||
/>
|
||||
<div className="relative w-full overflow-visible" role="separator">
|
||||
{/* Soft, dimmed upward ambient rainbow glow placed behind page content (-z-10) */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute -top-24 inset-x-0 -z-10 h-28 bg-gradient-to-r from-red-500/10 via-yellow-500/10 via-green-500/10 via-blue-500/10 to-purple-500/10 opacity-60 blur-3xl dark:from-red-500/15 dark:via-yellow-500/15 dark:via-green-500/15 dark:via-blue-500/15 dark:to-purple-500/15"
|
||||
/>
|
||||
{/* Primary rainbow line */}
|
||||
<div className="relative h-[4px] w-full bg-gradient-to-r from-red-500 via-yellow-500 via-green-500 via-blue-500 to-purple-500 opacity-80" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
@import '../../../../packages/ui/src/static/styles.css';
|
||||
@source '../../../../packages/ui/src/components';
|
||||
@source '../../../../packages/ui/src/lib';
|
||||
@source '../../../../packages/kits/src';
|
||||
@@ -2,8 +2,13 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom", "node"],
|
||||
"paths": {
|
||||
"#*": ["./*"],
|
||||
"@ermnvldmr/cli": ["../../../packages/cli/src"],
|
||||
"@ermnvldmr/cli/*": ["../../../packages/cli/src/*"],
|
||||
"@ermnvldmr/kits": ["../../../packages/kits/src"],
|
||||
"@ermnvldmr/kits/*": ["../../../packages/kits/src/*"],
|
||||
"@ermnvldmr/ssg": ["../../../packages/ssg/src"],
|
||||
"@ermnvldmr/ssg/*": ["../../../packages/ssg/src/*"],
|
||||
"@ermnvldmr/stl": ["../../../packages/stl/src"],
|
||||
@@ -11,6 +16,6 @@
|
||||
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", ".storybook/**/*"],
|
||||
"include": ["src/**/*", ".storybook/**/*", ".config/**/*"],
|
||||
"exclude": ["dist", "coverage", "node_modules", "**/*.config.*"]
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import { mergeConfig } from 'vitest/config';
|
||||
export default mergeConfig(reactVitestConfig, {
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
setupFiles: ['./src/setupTests.ts'],
|
||||
setupFiles: ['./.config/vitest/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user