From 7154a381cfd9ecad890bb51cd212929bedfebc16 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sat, 18 Apr 2026 21:40:18 +0800 Subject: [PATCH 1/7] feat: add Docker support and configuration files - Introduce Dockerfile for building the vnts2 application - Add .dockerignore to exclude unnecessary files from Docker context - Create docker-compose.yml for easy deployment - Update README with Docker usage instructions - Add example configuration file for user reference This commit establishes a Docker-based environment for the vnts2 server, enabling easier deployment and management. It includes necessary configurations for networking and persistence, ensuring a smooth setup process. --- .dockerignore | 10 ++ .gitignore | 7 ++ Dockerfile | 34 ++++++ README.md | 279 +++++++++++++++++++++++++++++++++++++++++++- README.zh.md | 277 +++++++++++++++++++++++++++++++++++++++++++ config.example.toml | 34 ++++++ data/.gitkeep | 1 + docker-compose.yml | 19 +++ rust-toolchain.toml | 3 + 9 files changed, 658 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.zh.md create mode 100644 config.example.toml create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 rust-toolchain.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b43959 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +target +data +logs +config.toml +cert.pem +key.pem +network_control.db +*.log diff --git a/.gitignore b/.gitignore index ea8c4bf..75726b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ /target +/data/* +!/data/.gitkeep +/config.toml +/cert.pem +/key.pem +/network_control.db +/logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ea3ecb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +ARG RUST_VERSION=1.93.1 + +FROM rust:${RUST_VERSION}-bookworm AS builder +WORKDIR /build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends protobuf-compiler pkg-config libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY rust-toolchain.toml Cargo.toml Cargo.lock build.rs ./ +COPY proto ./proto +COPY static ./static +COPY src ./src + +RUN cargo build --release --locked --bin vnts2 + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libsqlite3-0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app/data + +COPY --from=builder /build/target/release/vnts2 /usr/local/bin/vnts2 + +VOLUME ["/app/data"] + +EXPOSE 29871/tcp +EXPOSE 29872/tcp +EXPOSE 29872/udp +EXPOSE 29873/udp + +CMD ["vnts2"] diff --git a/README.md b/README.md index 50f70c9..78a5796 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,277 @@ -# vnts +# vnts2 -[vnt](https://github.com/vnt-dev/vnt)的服务端 +Server-side implementation for vnt-compatible networking, with support for: +- TCP over TLS +- WebSocket Secure (WSS) +- QUIC +- Optional web management UI/API +- Optional persistence with SQLite +- Optional peer-to-peer server federation -## 说明 +## Configuration -1. 支持quic、tcp(tls)和wss协议,会自动生成自签名证书,也可以手动替换 -2. 无参数启动后,会输出配置文件,可以修改配置文件 -3. --conf-example 参数查看配置文件示例 +The server reads `config.toml` from its current working directory by default. + +- If `config.toml` does not exist, the server will generate one automatically using built-in defaults. +- In Docker, the working directory is `/app/data`, so the effective config path is `/app/data/config.toml`. +- You can also pass a custom config path with `--conf /path/to/config.toml`. + +The repository includes a sample file at `config.example.toml`. + +### Configuration fields + +`tcp_bind` + +- TCP listener address for control traffic over TLS. +- Example: `0.0.0.0:29872` +- Remove this field to disable the TCP listener. + +`quic_bind` + +- QUIC listener address for control traffic. +- Example: `0.0.0.0:29872` +- Remove this field to disable QUIC. + +`ws_bind` + +- WSS listener address. +- Example: `0.0.0.0:29872` +- Remove this field to disable WSS. + +`network` + +- Default virtual network CIDR used by the server. +- Example: `10.26.0.0/24` + +`custom_nets` + +- Additional named virtual networks. +- TOML table format: + +```toml +[custom_nets] +office = "10.27.0.0/24" +lab = "10.28.0.0/24" +``` + +`white_list` + +- List of allowed network codes. +- Empty list means no whitelist restriction. + +`lease_duration` + +- Device IP lease duration in seconds. +- Example: `86400` for 24 hours. + +`web_bind` + +- Bind address for the web management UI and HTTP API. +- Example: `0.0.0.0:29871` +- Remove this field to disable the web UI/API. + +`username` + +- Username for the web management login. + +`password` + +- Password for the web management login. + +`persistence` + +- Enables persistence in SQLite. +- When enabled, the server stores networks, devices, and peer-server records in `network_control.db`. + +`cert` + +- Path to a PEM certificate file. +- If both `cert` and `key` are omitted, the server will generate `cert.pem` automatically. + +`key` + +- Path to a PEM private key file. +- If both `cert` and `key` are omitted, the server will generate `key.pem` automatically. + +`server_quic_bind` + +- Optional QUIC bind address for server-to-server federation. +- Example: `0.0.0.0:29873` + +`peer_servers` + +- List of upstream or sibling server addresses for federation. +- Example: + +```toml +peer_servers = ["server1.example.com:29873", "192.168.1.10:29873"] +``` + +`server_token` + +- Shared token used for inter-server authentication. +- Set this when `server_quic_bind` or `peer_servers` is enabled. + +### Runtime-generated files + +The server writes several files relative to its working directory: + +- `config.toml` +- `network_control.db` +- `cert.pem` +- `key.pem` +- `logs/` +- `logs/log4rs.yaml` + +If you use Docker, all of these should be stored in a mounted directory so they survive container recreation. + +### Notes + +- If `tcp_bind` and `ws_bind` use the same address, the server will multiplex TLS TCP and WSS on that single port. +- If `persistence = false`, runtime state is not stored in SQLite. +- Certificate paths may be absolute or relative. Relative paths are resolved from the process working directory. + +## Docker Deployment + +The repository already includes: + +- `Dockerfile` +- `docker-compose.yml` +- `rust-toolchain.toml` + +### Why the Rust version is pinned + +This project uses Rust 2024 edition syntax. To avoid syntax and toolchain mismatches across environments, the build is pinned to Rust `1.93.1` in both: + +- `rust-toolchain.toml` +- Docker build arg `RUST_VERSION` + +This is newer than the minimum required stable version and avoids edition-related compatibility problems. + +### Persisted data layout + +In Docker, the container runs with: + +- working directory: `/app/data` + +The compose file mounts: + +- host `./data` +- to container `/app/data` + +That means the following files will persist on the host: + +- `./data/config.toml` +- `./data/network_control.db` +- `./data/cert.pem` +- `./data/key.pem` +- `./data/logs/...` + +### Quick start with Docker Compose + +1. Copy the sample config: + +```bash +cp config.example.toml data/config.toml +``` + +2. Edit `data/config.toml` as needed. + +3. Build and start the service: + +```bash +docker compose up -d --build +``` + +4. Check logs: + +```bash +docker compose logs -f +``` + +5. Stop the service: + +```bash +docker compose down +``` + +### Default exposed ports + +- `29871/tcp`: web UI / HTTP API +- `29872/tcp`: TLS TCP control traffic +- `29872/udp`: QUIC control traffic +- `29873/udp`: optional peer-server QUIC federation + +If you do not use the web UI or peer federation, you may remove the corresponding published ports in `docker-compose.yml`. + +### Deploy from scratch + +If you want the server to generate its own default config and certificates: + +1. Create the data directory: + +```bash +mkdir -p data +``` + +2. Start the container: + +```bash +docker compose up -d --build +``` + +3. After the first start, inspect the generated files in `./data`. + +This is convenient for initial setup, but for controlled deployments it is better to create `data/config.toml` explicitly from `config.example.toml`. + +### Updating the deployment + +When the source code changes: + +```bash +docker compose up -d --build +``` + +Because all persistent state is stored in `./data`, recreating the container does not remove the database, config, certificates, or logs. + +### Standalone Docker commands + +Build: + +```bash +docker build -t vnts2:local . +``` + +Run: + +```bash +docker run -d \ + --name vnts2 \ + -p 29871:29871/tcp \ + -p 29872:29872/tcp \ + -p 29872:29872/udp \ + -p 29873:29873/udp \ + -v "$(pwd)/data:/app/data" \ + --restart unless-stopped \ + vnts2:local +``` + +### Troubleshooting + +If the container starts but no service is reachable: + +- Check whether the listener is enabled in `config.toml`. +- Check whether the port mapping matches the bind addresses in the config. +- Check `docker compose logs -f`. + +If the database is not persistent: + +- Confirm `persistence = true`. +- Confirm `./data` is mounted to `/app/data`. +- Confirm the server is actually using the expected working directory. + +If TLS files are missing: + +- The server only auto-generates `cert.pem` and `key.pem` when custom `cert` and `key` are not provided. +- Generated files are written into the working directory, which is `/app/data` in Docker. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..f196808 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,277 @@ +# vnts2 + +vnt 兼容服务端,支持以下能力: + +- TCP over TLS +- WebSocket Secure (WSS) +- QUIC +- 可选 Web 管理界面与 HTTP API +- 可选 SQLite 持久化 +- 可选服务端互联 + +## 配置说明 + +程序默认会从当前工作目录读取 `config.toml`。 + +- 如果 `config.toml` 不存在,程序会按内置默认值自动生成。 +- 在 Docker 中,工作目录是 `/app/data`,所以默认配置文件路径是 `/app/data/config.toml`。 +- 也可以通过 `--conf /path/to/config.toml` 指定自定义配置文件路径。 + +仓库中已提供示例配置文件:`config.example.toml`。 + +### 配置项说明 + +`tcp_bind` + +- TCP 控制通道监听地址,使用 TLS。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 TCP 监听。 + +`quic_bind` + +- QUIC 控制通道监听地址。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 QUIC。 + +`ws_bind` + +- WSS 监听地址。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 WSS。 + +`network` + +- 默认虚拟网段 CIDR。 +- 示例:`10.26.0.0/24` + +`custom_nets` + +- 额外的命名虚拟网段。 +- TOML 写法示例: + +```toml +[custom_nets] +office = "10.27.0.0/24" +lab = "10.28.0.0/24" +``` + +`white_list` + +- 允许使用的网络编号列表。 +- 空数组表示不启用白名单限制。 + +`lease_duration` + +- 设备 IP 租约时长,单位为秒。 +- 示例:`86400`,即 24 小时。 + +`web_bind` + +- Web 管理界面和 HTTP API 的监听地址。 +- 示例:`0.0.0.0:29871` +- 删除该字段即可关闭 Web 管理功能。 + +`username` + +- Web 管理登录用户名。 + +`password` + +- Web 管理登录密码。 + +`persistence` + +- 是否启用 SQLite 持久化。 +- 开启后,网络、设备和互联服务端记录会保存到 `network_control.db`。 + +`cert` + +- PEM 证书文件路径。 +- 如果 `cert` 和 `key` 都不配置,程序会自动生成 `cert.pem`。 + +`key` + +- PEM 私钥文件路径。 +- 如果 `cert` 和 `key` 都不配置,程序会自动生成 `key.pem`。 + +`server_quic_bind` + +- 可选的服务端互联 QUIC 监听地址。 +- 示例:`0.0.0.0:29873` + +`peer_servers` + +- 需要互联的上游或同级服务端地址列表。 +- 示例: + +```toml +peer_servers = ["server1.example.com:29873", "192.168.1.10:29873"] +``` + +`server_token` + +- 服务端之间鉴权用的共享令牌。 +- 开启 `server_quic_bind` 或配置 `peer_servers` 时建议同时设置。 + +### 运行时生成文件 + +程序会在当前工作目录下写入以下文件: + +- `config.toml` +- `network_control.db` +- `cert.pem` +- `key.pem` +- `logs/` +- `logs/log4rs.yaml` + +如果使用 Docker,建议把这些文件统一放到挂载目录中,避免容器重建后丢失。 + +### 额外说明 + +- 如果 `tcp_bind` 和 `ws_bind` 配置为同一个地址,程序会在同一端口上复用 TLS TCP 和 WSS。 +- 如果 `persistence = false`,运行状态不会写入 SQLite。 +- `cert` 和 `key` 支持绝对路径与相对路径。相对路径基于进程工作目录解析。 + +## Docker 部署说明 + +仓库中已包含以下文件: + +- `Dockerfile` +- `docker-compose.yml` +- `rust-toolchain.toml` + +### 为什么固定 Rust 版本 + +当前项目使用 Rust 2024 edition 语法。为了避免不同环境的 toolchain 版本差异导致语法或构建不兼容,仓库中已把构建版本固定为 `1.93.1`,位置如下: + +- `rust-toolchain.toml` +- `docker-compose.yml` 中的 `RUST_VERSION` + +这个版本高于项目实际最低要求,主要目的是避免 2024 edition 和相关语法特性带来的兼容问题。 + +### 持久化目录布局 + +Docker 中容器运行时的工作目录为: + +- `/app/data` + +`docker-compose.yml` 中已经把宿主机目录挂载到容器内: + +- 宿主机:`./data` +- 容器内:`/app/data` + +因此下面这些文件会直接持久化到宿主机: + +- `./data/config.toml` +- `./data/network_control.db` +- `./data/cert.pem` +- `./data/key.pem` +- `./data/logs/...` + +### 使用 Docker Compose 快速部署 + +1. 复制示例配置: + +```bash +cp config.example.toml data/config.toml +``` + +2. 按需修改 `data/config.toml`。 + +3. 构建并启动服务: + +```bash +docker compose up -d --build +``` + +4. 查看日志: + +```bash +docker compose logs -f +``` + +5. 停止服务: + +```bash +docker compose down +``` + +### 默认暴露端口 + +- `29871/tcp`:Web 管理界面 / HTTP API +- `29872/tcp`:TLS TCP 控制通道 +- `29872/udp`:QUIC 控制通道 +- `29873/udp`:可选的服务端互联 QUIC 端口 + +如果不使用 Web 管理或服务端互联,可以在 `docker-compose.yml` 中删除对应端口映射。 + +### 从空目录启动 + +如果希望让程序第一次启动时自动生成默认配置和证书,可以这样做: + +1. 创建数据目录: + +```bash +mkdir -p data +``` + +2. 启动容器: + +```bash +docker compose up -d --build +``` + +3. 首次启动完成后,检查 `./data` 下自动生成的文件。 + +这种方式适合初始化测试;正式部署时,建议先基于 `config.example.toml` 手动生成 `data/config.toml`。 + +### 更新部署 + +代码更新后,重新执行: + +```bash +docker compose up -d --build +``` + +因为所有持久化数据都保存在 `./data`,容器重建后不会丢失数据库、配置、证书和日志。 + +### 单独使用 Docker 命令 + +构建镜像: + +```bash +docker build -t vnts2:local . +``` + +运行容器: + +```bash +docker run -d \ + --name vnts2 \ + -p 29871:29871/tcp \ + -p 29872:29872/tcp \ + -p 29872:29872/udp \ + -p 29873:29873/udp \ + -v "$(pwd)/data:/app/data" \ + --restart unless-stopped \ + vnts2:local +``` + +### 排查建议 + +如果容器启动了但服务无法访问: + +- 检查 `config.toml` 中对应监听项是否启用。 +- 检查端口映射是否和配置中的监听地址一致。 +- 检查 `docker compose logs -f` 输出。 + +如果数据库没有持久化: + +- 确认 `persistence = true`。 +- 确认 `./data` 已挂载到 `/app/data`。 +- 确认程序实际工作目录就是预期的 `/app/data`。 + +如果没有生成 TLS 文件: + +- 只有在未显式配置 `cert` 和 `key` 时,程序才会自动生成 `cert.pem` 和 `key.pem`。 +- 自动生成的证书和私钥会写入工作目录,也就是 Docker 中的 `/app/data`。 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..0ee6ff1 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,34 @@ +# Bind addresses. Remove a line to disable that listener. +tcp_bind = "0.0.0.0:29872" +quic_bind = "0.0.0.0:29872" +ws_bind = "0.0.0.0:29872" + +# Default virtual network. +network = "10.26.0.0/24" + +# Whitelisted network codes. +white_list = [] + +# Lease duration in seconds. +lease_duration = 86400 + +# Web admin UI/API. +web_bind = "0.0.0.0:29871" +username = "admin" +password = "admin" + +# Persist networks, devices and peer server records into SQLite. +persistence = true + +# Leave these unset to let the server auto-generate cert.pem / key.pem. +# cert = "cert.pem" +# key = "key.pem" + +# Optional peer-server settings. +# server_quic_bind = "0.0.0.0:29873" +# peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] +# server_token = "your-secret-token" + +[custom_nets] +# net1 = "10.25.0.0/24" +# net2 = "10.27.1.0/24" diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8cf3207 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + vnts: + build: + context: . + args: + RUST_VERSION: 1.93.1 + image: vnts2:local + container_name: vnts2 + restart: unless-stopped + volumes: + # The app writes config.toml, network_control.db, cert.pem, key.pem and logs + # relative to its working directory. Persist all of them in ./data. + - ./data:/app/data + ports: + - "29871:29871/tcp" + - "29872:29872/tcp" + - "29872:29872/udp" + # Optional peer server port used by server_quic_bind. + - "29873:29873/udp" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f2c0da8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.93.1" +profile = "minimal" From d73689c3ba166e5dbbf8db8836c81a3ab228dcb4 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sat, 18 Apr 2026 22:22:06 +0800 Subject: [PATCH 2/7] feat: enhance config loading with defaults - Introduce LoadedConfig struct to encapsulate config loading - Update load_from to load_with_meta for better error handling - Log warnings when using default credentials for web auth - Create default config if it does not exist This commit improves the configuration loading process by providing a structured way to handle default values and logging warnings when defaults are used. It ensures that users are informed about the configuration state and encourages them to set custom credentials. --- docker-compose.yml | 1 + src/main.rs | 46 +++++++++++++++++++++++++++++++++++++++++---- src/utils/config.rs | 43 ++++++++++++++++++++++++++++-------------- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8cf3207..7c07b94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: image: vnts2:local container_name: vnts2 restart: unless-stopped + command: ["vnts2", "--conf", "/app/data/config.toml"] volumes: # The app writes config.toml, network_control.db, cert.pem, key.pem and logs # relative to its working directory. Persist all of them in ./data. diff --git a/src/main.rs b/src/main.rs index 2125218..1aabb96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use crate::server::TurnConfig; use crate::server::control_server::service::ControlService; use crate::server::peer_server::PeerServerManager; -use crate::utils::config::ConfigFile; +use crate::utils::config::{ConfigFile, LoadedConfig}; use clap::Parser; use std::path::PathBuf; use std::sync::Arc; @@ -32,13 +32,25 @@ async fn main() { } utils::log::log_init("vnts2"); log::info!("version: {:?}", env!("CARGO_PKG_VERSION")); - let conf = match ConfigFile::load_from(args.conf) { + let loaded = match ConfigFile::load_with_meta(args.conf) { Ok(conf) => conf, Err(e) => { log::error!("{e:?}"); panic!("{e:?}") } }; + let LoadedConfig { + path: config_path, + config: conf, + created_default, + } = loaded; + log::info!("Loaded config from {}", config_path.display()); + if created_default { + log::warn!( + "Config file did not exist. A default config was created at {}", + config_path.display() + ); + } if conf.persistence { if let Err(e) = server::control_server::db::init_db_pool().await { log::error!("{:?}", e); @@ -65,8 +77,34 @@ async fn main() { }; let web_bind = conf.web_bind; - let username = conf.username.unwrap_or("admin".to_string()); - let password = conf.password.unwrap_or("admin".to_string()); + let username = conf.username.unwrap_or_else(|| { + log::warn!( + "username is not set in {}. Falling back to default username 'admin'", + config_path.display() + ); + "admin".to_string() + }); + let password = conf.password.unwrap_or_else(|| { + log::warn!( + "password is not set in {}. Falling back to default password", + config_path.display() + ); + "admin".to_string() + }); + if let Some(bind_addr) = web_bind { + let using_default_credentials = username == "admin" && password == "admin"; + log::info!( + "Web auth loaded for {} with username '{}'", + bind_addr, + username + ); + if using_default_credentials { + log::warn!( + "Web auth is still using default credentials admin/admin from {}", + config_path.display() + ); + } + } let control_service = ControlService::new( conf.network, diff --git a/src/utils/config.rs b/src/utils/config.rs index 8d83f41..9aeef33 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -5,6 +5,13 @@ use std::io::Write; use std::net::{Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; +#[derive(Debug)] +pub struct LoadedConfig { + pub path: PathBuf, + pub config: ConfigFile, + pub created_default: bool, +} + #[derive(Debug, Deserialize, Serialize)] pub struct ConfigFile { pub tcp_bind: Option, @@ -59,20 +66,28 @@ impl ConfigFile { Ok(()) } pub fn load_from(path: Option) -> anyhow::Result { - let path = if let Some(path) = path { - path - } else { - let path = Path::new("config.toml"); - if !path.exists() { - let file = Self::default(); - _ = file.save_to(path); - return Ok(file); - } - path.to_path_buf() - }; - let content = std::fs::read_to_string(path)?; + Ok(Self::load_with_meta(path)?.config) + } + + pub fn load_with_meta(path: Option) -> anyhow::Result { + let path = path.unwrap_or_else(|| Path::new("config.toml").to_path_buf()); + if !path.exists() { + let file = Self::default(); + file.save_to(&path)?; + return Ok(LoadedConfig { + path, + config: file, + created_default: true, + }); + } + + let content = std::fs::read_to_string(&path)?; let cfg: ConfigFile = toml::from_str(&content)?; - Ok(cfg) + Ok(LoadedConfig { + path, + config: cfg, + created_default: false, + }) } } @@ -119,4 +134,4 @@ key = "key.pem" # net2 = "10.27.1.0/24" "#; println!("{}", str); -} \ No newline at end of file +} From a297fba4d1ab27ff92495690df3d670a14dc42d6 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sat, 18 Apr 2026 22:42:51 +0800 Subject: [PATCH 3/7] feat: add network secrets management - Introduce `network_secrets` to store secrets for each network code. - Implement validation for network secrets during registration. - Update configuration to include `network_secrets` and ensure strong secret generation. - Refactor network configuration handling to improve error reporting and validation. This change enhances security by requiring a secret for each network code, ensuring that only authorized networks can register. It also improves the configuration management by validating the presence and strength of secrets, preventing misconfigurations that could lead to security vulnerabilities. --- src/server/control_server/service.rs | 122 +++++++++++------- src/utils/config.rs | 182 +++++++++++++++++++++++---- 2 files changed, 235 insertions(+), 69 deletions(-) diff --git a/src/server/control_server/service.rs b/src/server/control_server/service.rs index 96a78b1..125b3d7 100644 --- a/src/server/control_server/service.rs +++ b/src/server/control_server/service.rs @@ -33,9 +33,9 @@ pub struct NetworkConfig { #[derive(Clone)] pub struct ControlService { - default_net: Ipv4Net, default_lease_duration: Duration, db_nets: Arc>>, + network_secrets: Arc>, network_state_provider: NetworkStateProvider, network_init_locks: Arc>>>, peer_manager: Arc>>>, @@ -43,19 +43,27 @@ pub struct ControlService { impl ControlService { pub async fn new( + default_network_code: String, default_net: Ipv4Net, custom_nets: HashMap, + network_secrets: HashMap, lease_duration: Duration, ) -> Self { let network_states = Arc::new(DashMap::new()); - Self::save_config_networks_to_db(&default_net, &custom_nets, lease_duration).await; + Self::save_config_networks_to_db( + &default_network_code, + &default_net, + &custom_nets, + lease_duration, + ) + .await; let db_nets = Self::load_networks_from_db().await; let service = Self { - default_net, default_lease_duration: lease_duration, db_nets: Arc::new(RwLock::new(db_nets)), + network_secrets: Arc::new(network_secrets), network_state_provider: NetworkStateProvider::new(network_states), network_init_locks: Arc::new(DashMap::new()), peer_manager: Arc::new(RwLock::new(None)), @@ -71,7 +79,8 @@ impl ControlService { } async fn save_config_networks_to_db( - _default_net: &Ipv4Net, + default_network_code: &str, + default_net: &Ipv4Net, custom_nets: &HashMap, lease_duration: Duration, ) { @@ -81,6 +90,28 @@ impl ControlService { .as_secs() as i64; let lease_secs = lease_duration.as_secs() as i64; + let default_gateway = Ipv4Addr::from(u32::from(default_net.network()) + 1); + let default_record = NetworkRecord { + network_code: default_network_code.to_string(), + gateway: default_gateway.to_string(), + netmask: default_net.prefix_len(), + lease_duration: lease_secs, + source: NetworkSource::Config, + created_at: now, + }; + match db::save_network_if_not_exists(&default_record).await { + Ok(true) => log::info!( + "Initialized default network '{}' from config", + default_network_code + ), + Ok(false) => {} + Err(e) => log::error!( + "Failed to save default network {}: {}", + default_network_code, + e + ), + } + for (code, net) in custom_nets { let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); let record = NetworkRecord { @@ -129,15 +160,10 @@ impl ControlService { sender: Sender, ) -> anyhow::Result { reg_req.check()?; + self.validate_registration(®_req)?; let network_code = reg_req.network_code.clone(); let registration_mode = reg_req.registration_mode; - - let is_new_network = !self.db_nets.read().contains_key(®_req.network_code); - let config = self.network_config(®_req.network_code, reg_req.ip); - - if is_new_network { - self.db_nets.write().insert(reg_req.network_code.clone(), config); - } + let config = self.network_config(®_req.network_code)?; let state = self .get_or_create_network_state(reg_req.network_code.clone(), config) @@ -172,27 +198,6 @@ impl ControlService { ) }; - if is_new_network { - let gateway = Ipv4Addr::from(u32::from(config.net.network()) + 1); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let record = NetworkRecord { - network_code: network_code.clone(), - gateway: gateway.to_string(), - netmask: config.net.prefix_len(), - lease_duration: config.lease_duration.as_secs() as i64, - source: NetworkSource::DeviceRegister, - created_at: now, - }; - tokio::spawn(async move { - if let Err(e) = db::save_network(&record).await { - log::error!("Failed to save new network: {:?}", e); - } - }); - } - if matches!(registration_mode, RegistrationMode::Normal) { if let Some(entry) = entry { let nc = network_code.clone(); @@ -208,20 +213,47 @@ impl ControlService { Ok(session) } - fn network_config(&self, network_code: &str, ip: Option) -> NetworkConfig { - if let Some(config) = self.db_nets.read().get(network_code) { - return *config; - } - let net = if let Some(ip) = ip { - Ipv4Net::new_assert(Ipv4Net::new_assert(ip, 24).network(), 24) - } else { - self.default_net + fn network_config(&self, network_code: &str) -> anyhow::Result { + self.db_nets + .read() + .get(network_code) + .copied() + .ok_or_else(|| anyhow::anyhow!("network_code '{}' is not allowed", network_code)) + } + + fn validate_registration(&self, reg_req: &RegRequestMsg) -> anyhow::Result<()> { + let Some(expected_secret) = self.network_secrets.get(®_req.network_code) else { + bail!( + "network_code '{}' is not allowed by server configuration", + reg_req.network_code + ); }; - NetworkConfig { - net, - lease_duration: self.default_lease_duration, - source: NetworkSource::DeviceRegister, + + let Some(provided_secret) = reg_req.key_sign.as_deref() else { + bail!( + "network_code '{}' requires a network secret", + reg_req.network_code + ); + }; + + if provided_secret != expected_secret { + bail!( + "invalid network secret for network_code '{}'", + reg_req.network_code + ); } + + Ok(()) + } + + fn ensure_network_secret_configured(&self, network_code: &str) -> anyhow::Result<()> { + if !self.network_secrets.contains_key(network_code) { + bail!( + "network_code '{}' has no configured secret. Add it under [network_secrets] and restart the server", + network_code + ); + } + Ok(()) } /// DCL: 获取或创建 NetworkState @@ -378,6 +410,8 @@ impl ControlService { netmask: u8, lease_duration: Option, ) -> anyhow::Result<()> { + self.ensure_network_secret_configured(&network_code)?; + if self.db_nets.read().contains_key(&network_code) { bail!("网络编号 '{}' 已存在", network_code); } diff --git a/src/utils/config.rs b/src/utils/config.rs index 9aeef33..3868fc2 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,4 +1,6 @@ +use anyhow::{Context, bail}; use ipnet::Ipv4Net; +use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::io::Write; @@ -19,8 +21,14 @@ pub struct ConfigFile { pub ws_bind: Option, pub cert: Option, pub key: Option, + #[serde(default = "default_network_code")] + pub default_network_code: String, pub network: Ipv4Net, + #[serde(default)] pub custom_nets: HashMap, + #[serde(default)] + pub network_secrets: HashMap, + #[serde(default)] pub white_list: HashSet, pub lease_duration: u64, pub web_bind: Option, @@ -33,16 +41,44 @@ pub struct ConfigFile { pub peer_servers: Vec, pub server_token: Option, } + +fn default_network_code() -> String { + "default".to_string() +} + +fn generate_strong_secret() -> String { + const SECRET_CHARSET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!@#$%^&*+=:"; + + let mut rng = rand::rng(); + let mut secret = String::with_capacity(32); + secret.push('A'); + secret.push('a'); + secret.push('0'); + secret.push('!'); + for _ in 4..32 { + let idx = rng.random_range(0..SECRET_CHARSET.len()); + secret.push(SECRET_CHARSET[idx] as char); + } + secret +} + impl Default for ConfigFile { fn default() -> Self { + let default_network_code = default_network_code(); + let mut network_secrets = HashMap::new(); + network_secrets.insert(default_network_code.clone(), generate_strong_secret()); + Self { tcp_bind: Some("0.0.0.0:29872".parse().unwrap()), quic_bind: Some("0.0.0.0:29872".parse().unwrap()), ws_bind: Some("0.0.0.0:29872".parse().unwrap()), cert: None, key: None, + default_network_code, network: Ipv4Net::new_assert(Ipv4Addr::new(10, 26, 0, 0), 24), custom_nets: Default::default(), + network_secrets, white_list: Default::default(), lease_duration: 24 * 60 * 60, web_bind: Some("0.0.0.0:29871".parse().unwrap()), @@ -65,6 +101,7 @@ impl ConfigFile { Ok(()) } + pub fn load_from(path: Option) -> anyhow::Result { Ok(Self::load_with_meta(path)?.config) } @@ -82,56 +119,151 @@ impl ConfigFile { } let content = std::fs::read_to_string(&path)?; - let cfg: ConfigFile = toml::from_str(&content)?; + let cfg: ConfigFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse config file {}", path.display()))?; + cfg.validate() + .with_context(|| format!("Invalid config file {}", path.display()))?; + Ok(LoadedConfig { path, config: cfg, created_default: false, }) } + + pub fn validate(&self) -> anyhow::Result<()> { + validate_network_code(&self.default_network_code, "default_network_code")?; + + if self.custom_nets.contains_key(&self.default_network_code) { + bail!( + "default_network_code '{}' must not also appear in [custom_nets]", + self.default_network_code + ); + } + + for code in self.custom_nets.keys() { + validate_network_code(code, "custom_nets")?; + } + + for code in self.white_list.iter() { + validate_network_code(code, "white_list")?; + } + + let mut required_codes = HashSet::new(); + required_codes.insert(self.default_network_code.clone()); + required_codes.extend(self.custom_nets.keys().cloned()); + + for code in required_codes.iter() { + let Some(secret) = self.network_secrets.get(code) else { + bail!( + "Missing secret for network_code '{}'. Add it under [network_secrets]", + code + ); + }; + validate_network_secret(code, secret)?; + } + + for code in self.network_secrets.keys() { + validate_network_code(code, "network_secrets")?; + if !required_codes.contains(code) { + bail!( + "network_secrets contains unknown network_code '{}'. Add it to custom_nets or make it the default_network_code", + code + ); + } + } + + Ok(()) + } +} + +fn validate_network_code(code: &str, field_name: &str) -> anyhow::Result<()> { + if code.trim().is_empty() { + bail!("{field_name} contains an empty network_code"); + } + if code.len() > 32 { + bail!( + "{field_name} contains network_code '{}' longer than 32 characters", + code + ); + } + Ok(()) } -pub fn print_example(){ - let str = r#"# 绑定tcp地址,不写则不启用tcp服务 +fn validate_network_secret(network_code: &str, secret: &str) -> anyhow::Result<()> { + if secret.len() < 24 { + bail!( + "Secret for network_code '{}' is too short. Use at least 24 characters", + network_code + ); + } + if secret.chars().any(char::is_whitespace) { + bail!( + "Secret for network_code '{}' must not contain whitespace", + network_code + ); + } + + let has_lower = secret.chars().any(|c| c.is_ascii_lowercase()); + let has_upper = secret.chars().any(|c| c.is_ascii_uppercase()); + let has_digit = secret.chars().any(|c| c.is_ascii_digit()); + let has_symbol = secret.chars().any(|c| !c.is_ascii_alphanumeric()); + let category_count = [has_lower, has_upper, has_digit, has_symbol] + .into_iter() + .filter(|v| *v) + .count(); + let is_hex = secret.chars().all(|c| c.is_ascii_hexdigit()); + + if category_count < 3 && !(is_hex && secret.len() >= 32) { + bail!( + "Secret for network_code '{}' is too weak. Use a 24+ char mixed secret, or a 32+ char random hex secret", + network_code + ); + } + + Ok(()) +} + +pub fn print_example() { + let str = r#"# Listener addresses. Remove a line to disable that protocol. tcp_bind = "0.0.0.0:29872" -# 绑定quic地址,不写则不启用quic服务 quic_bind = "0.0.0.0:29872" -# 绑定wss地址,不写则不启用wss服务 ws_bind = "0.0.0.0:29872" -# 默认虚拟网段 + +# Default network name and CIDR. +default_network_code = "default" network = "10.26.0.0/24" -# 网络编号白名单 + +# Optional allow-list. This does not replace network secrets. white_list = [] -# IP租约时长,单位秒,默认24小时,离线超过这个时间IP就会被回收 + +# Lease duration in seconds. lease_duration = 86400 -# Web管理端绑定地址,不写则不启用web服务 + +# Web admin UI/API login. web_bind = "0.0.0.0:29871" -# 管理端登录用户名密码 username = "admin" -# 管理端登录用户密码 password = "admin" -# 是否启用数据持久化 + +# SQLite persistence. persistence = true -# tls证书不填时将自动生成 -# 自定义tls证书路径 -cert = "cert.pem" -# 自定义tls私钥路径 -key = "key.pem" +# Leave these unset to auto-generate cert.pem and key.pem. +# cert = "cert.pem" +# key = "key.pem" -# 服务端互联配置(可选) -# 服务端之间通信的UDP端口,不填则不启用服务端互联 +# Optional peer-server settings. # server_quic_bind = "0.0.0.0:29873" -# 其他服务器地址列表 # peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] -# 服务器验证码,用于服务器之间的身份验证 # server_token = "your-secret-token" -# 自定义虚拟网段 格式:网络编号 = "网段" -[custom_nets] +# Every allowed network_code must have a strong secret here. +[network_secrets] +default = "Use-A-Long-Strong-Secret-At-Least-24-Chars!" -# net1 = "10.25.0.0/24" -# net2 = "10.27.1.0/24" +[custom_nets] +# office = "10.25.0.0/24" +# dev = "10.27.1.0/24" "#; println!("{}", str); } From 505ff3b2c4c58e431d69142f81430ee1b4d60620 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sat, 18 Apr 2026 22:57:19 +0800 Subject: [PATCH 4/7] feat: refactor network configuration handling and logging --- config.example.toml | 34 ----------- src/main.rs | 8 ++- src/server/control_server/service.rs | 39 +------------ src/utils/config.rs | 84 ++++++++++++++-------------- 4 files changed, 50 insertions(+), 115 deletions(-) delete mode 100644 config.example.toml diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index 0ee6ff1..0000000 --- a/config.example.toml +++ /dev/null @@ -1,34 +0,0 @@ -# Bind addresses. Remove a line to disable that listener. -tcp_bind = "0.0.0.0:29872" -quic_bind = "0.0.0.0:29872" -ws_bind = "0.0.0.0:29872" - -# Default virtual network. -network = "10.26.0.0/24" - -# Whitelisted network codes. -white_list = [] - -# Lease duration in seconds. -lease_duration = 86400 - -# Web admin UI/API. -web_bind = "0.0.0.0:29871" -username = "admin" -password = "admin" - -# Persist networks, devices and peer server records into SQLite. -persistence = true - -# Leave these unset to let the server auto-generate cert.pem / key.pem. -# cert = "cert.pem" -# key = "key.pem" - -# Optional peer-server settings. -# server_quic_bind = "0.0.0.0:29873" -# peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] -# server_token = "your-secret-token" - -[custom_nets] -# net1 = "10.25.0.0/24" -# net2 = "10.27.1.0/24" diff --git a/src/main.rs b/src/main.rs index 1aabb96..0ae749a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -106,9 +106,15 @@ async fn main() { } } + log::info!( + "Loaded {} configured networks and {} network secrets", + conf.custom_nets.len(), + conf.network_secrets.len() + ); + let control_service = ControlService::new( - conf.network, conf.custom_nets, + conf.network_secrets, Duration::from_secs(conf.lease_duration), ) .await; diff --git a/src/server/control_server/service.rs b/src/server/control_server/service.rs index 125b3d7..57dba5e 100644 --- a/src/server/control_server/service.rs +++ b/src/server/control_server/service.rs @@ -43,21 +43,13 @@ pub struct ControlService { impl ControlService { pub async fn new( - default_network_code: String, - default_net: Ipv4Net, custom_nets: HashMap, network_secrets: HashMap, lease_duration: Duration, ) -> Self { let network_states = Arc::new(DashMap::new()); - Self::save_config_networks_to_db( - &default_network_code, - &default_net, - &custom_nets, - lease_duration, - ) - .await; + Self::save_config_networks_to_db(&custom_nets, lease_duration).await; let db_nets = Self::load_networks_from_db().await; let service = Self { @@ -78,40 +70,13 @@ impl ControlService { service } - async fn save_config_networks_to_db( - default_network_code: &str, - default_net: &Ipv4Net, - custom_nets: &HashMap, - lease_duration: Duration, - ) { + async fn save_config_networks_to_db(custom_nets: &HashMap, lease_duration: Duration) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; let lease_secs = lease_duration.as_secs() as i64; - let default_gateway = Ipv4Addr::from(u32::from(default_net.network()) + 1); - let default_record = NetworkRecord { - network_code: default_network_code.to_string(), - gateway: default_gateway.to_string(), - netmask: default_net.prefix_len(), - lease_duration: lease_secs, - source: NetworkSource::Config, - created_at: now, - }; - match db::save_network_if_not_exists(&default_record).await { - Ok(true) => log::info!( - "Initialized default network '{}' from config", - default_network_code - ), - Ok(false) => {} - Err(e) => log::error!( - "Failed to save default network {}: {}", - default_network_code, - e - ), - } - for (code, net) in custom_nets { let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); let record = NetworkRecord { diff --git a/src/utils/config.rs b/src/utils/config.rs index 3868fc2..6c01bef 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -7,6 +7,8 @@ use std::io::Write; use std::net::{Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; +pub const DEFAULT_NETWORK_CODE: &str = "default"; + #[derive(Debug)] pub struct LoadedConfig { pub path: PathBuf, @@ -21,13 +23,6 @@ pub struct ConfigFile { pub ws_bind: Option, pub cert: Option, pub key: Option, - #[serde(default = "default_network_code")] - pub default_network_code: String, - pub network: Ipv4Net, - #[serde(default)] - pub custom_nets: HashMap, - #[serde(default)] - pub network_secrets: HashMap, #[serde(default)] pub white_list: HashSet, pub lease_duration: u64, @@ -40,10 +35,10 @@ pub struct ConfigFile { #[serde(default)] pub peer_servers: Vec, pub server_token: Option, -} - -fn default_network_code() -> String { - "default".to_string() + #[serde(default)] + pub custom_nets: HashMap, + #[serde(default)] + pub network_secrets: HashMap, } fn generate_strong_secret() -> String { @@ -65,9 +60,14 @@ fn generate_strong_secret() -> String { impl Default for ConfigFile { fn default() -> Self { - let default_network_code = default_network_code(); + let mut custom_nets = HashMap::new(); + custom_nets.insert( + DEFAULT_NETWORK_CODE.to_string(), + Ipv4Net::new_assert(Ipv4Addr::new(10, 26, 0, 0), 24), + ); + let mut network_secrets = HashMap::new(); - network_secrets.insert(default_network_code.clone(), generate_strong_secret()); + network_secrets.insert(DEFAULT_NETWORK_CODE.to_string(), generate_strong_secret()); Self { tcp_bind: Some("0.0.0.0:29872".parse().unwrap()), @@ -75,9 +75,7 @@ impl Default for ConfigFile { ws_bind: Some("0.0.0.0:29872".parse().unwrap()), cert: None, key: None, - default_network_code, - network: Ipv4Net::new_assert(Ipv4Addr::new(10, 26, 0, 0), 24), - custom_nets: Default::default(), + custom_nets, network_secrets, white_list: Default::default(), lease_duration: 24 * 60 * 60, @@ -110,6 +108,8 @@ impl ConfigFile { let path = path.unwrap_or_else(|| Path::new("config.toml").to_path_buf()); if !path.exists() { let file = Self::default(); + file.validate() + .with_context(|| "Generated default config is invalid".to_string())?; file.save_to(&path)?; return Ok(LoadedConfig { path, @@ -132,12 +132,18 @@ impl ConfigFile { } pub fn validate(&self) -> anyhow::Result<()> { - validate_network_code(&self.default_network_code, "default_network_code")?; + if self.custom_nets.is_empty() { + bail!( + "custom_nets must contain at least one network, and it must include '{}'", + DEFAULT_NETWORK_CODE + ); + } - if self.custom_nets.contains_key(&self.default_network_code) { + if !self.custom_nets.contains_key(DEFAULT_NETWORK_CODE) { bail!( - "default_network_code '{}' must not also appear in [custom_nets]", - self.default_network_code + "custom_nets must contain '{}'. Clients must use network_code='{}' to join the default network", + DEFAULT_NETWORK_CODE, + DEFAULT_NETWORK_CODE ); } @@ -149,11 +155,12 @@ impl ConfigFile { validate_network_code(code, "white_list")?; } - let mut required_codes = HashSet::new(); - required_codes.insert(self.default_network_code.clone()); - required_codes.extend(self.custom_nets.keys().cloned()); + for (code, secret) in self.network_secrets.iter() { + validate_network_code(code, "network_secrets")?; + validate_network_secret(code, secret)?; + } - for code in required_codes.iter() { + for code in self.custom_nets.keys() { let Some(secret) = self.network_secrets.get(code) else { bail!( "Missing secret for network_code '{}'. Add it under [network_secrets]", @@ -163,16 +170,6 @@ impl ConfigFile { validate_network_secret(code, secret)?; } - for code in self.network_secrets.keys() { - validate_network_code(code, "network_secrets")?; - if !required_codes.contains(code) { - bail!( - "network_secrets contains unknown network_code '{}'. Add it to custom_nets or make it the default_network_code", - code - ); - } - } - Ok(()) } } @@ -230,11 +227,7 @@ tcp_bind = "0.0.0.0:29872" quic_bind = "0.0.0.0:29872" ws_bind = "0.0.0.0:29872" -# Default network name and CIDR. -default_network_code = "default" -network = "10.26.0.0/24" - -# Optional allow-list. This does not replace network secrets. +# Optional allow-list. Currently not enforced for registration. white_list = [] # Lease duration in seconds. @@ -257,13 +250,18 @@ persistence = true # peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] # server_token = "your-secret-token" -# Every allowed network_code must have a strong secret here. -[network_secrets] -default = "Use-A-Long-Strong-Secret-At-Least-24-Chars!" - +# Every allowed network_code must be declared here. +# Clients must explicitly use network_code = "default" to join the default network. [custom_nets] +default = "10.26.0.0/24" # office = "10.25.0.0/24" # dev = "10.27.1.0/24" + +# Every configured network_code must have a strong secret here. +[network_secrets] +default = "Use-A-Long-Strong-Secret-At-Least-24-Chars!" +# office = "Replace-With-A-Different-Long-Strong-Secret!" +# dev = "Another-Different-Long-Strong-Secret!" "#; println!("{}", str); } From 7ed7af0f36b6d38a0c458be7bc608d4f0f760e59 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sat, 18 Apr 2026 23:25:54 +0800 Subject: [PATCH 5/7] feat: implement network secret management - Update README files to reflect correct sample config path - Add 'secret' field to network structures and database - Modify network management functions to handle secrets - Enhance UI to allow input and display of network secrets This commit introduces a new feature for managing network secrets, which improves security by requiring a secret for each network. The documentation has been updated to guide users on the new configuration requirements. Additionally, the UI has been enhanced to support the input and display of network secrets, ensuring a better user experience. --- README.md | 6 +- README.zh.md | 6 +- src/http/web_server.rs | 18 +- src/main.rs | 2 +- src/server/control_server/db.rs | 31 +++- src/server/control_server/service.rs | 264 +++++++++++++++++++++------ src/utils/config.rs | 20 +- static/index.html | 73 +++++++- 8 files changed, 324 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 78a5796..62bc28b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The server reads `config.toml` from its current working directory by default. - In Docker, the working directory is `/app/data`, so the effective config path is `/app/data/config.toml`. - You can also pass a custom config path with `--conf /path/to/config.toml`. -The repository includes a sample file at `config.example.toml`. +The repository includes a sample file at `data/config.example.toml`. ### Configuration fields @@ -173,7 +173,7 @@ That means the following files will persist on the host: 1. Copy the sample config: ```bash -cp config.example.toml data/config.toml +cp data/config.example.toml data/config.toml ``` 2. Edit `data/config.toml` as needed. @@ -223,7 +223,7 @@ docker compose up -d --build 3. After the first start, inspect the generated files in `./data`. -This is convenient for initial setup, but for controlled deployments it is better to create `data/config.toml` explicitly from `config.example.toml`. +This is convenient for initial setup, but for controlled deployments it is better to create `data/config.toml` explicitly from `data/config.example.toml`. ### Updating the deployment diff --git a/README.zh.md b/README.zh.md index f196808..653281f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -17,7 +17,7 @@ vnt 兼容服务端,支持以下能力: - 在 Docker 中,工作目录是 `/app/data`,所以默认配置文件路径是 `/app/data/config.toml`。 - 也可以通过 `--conf /path/to/config.toml` 指定自定义配置文件路径。 -仓库中已提供示例配置文件:`config.example.toml`。 +仓库中已提供示例配置文件:`data/config.example.toml`。 ### 配置项说明 @@ -173,7 +173,7 @@ Docker 中容器运行时的工作目录为: 1. 复制示例配置: ```bash -cp config.example.toml data/config.toml +cp data/config.example.toml data/config.toml ``` 2. 按需修改 `data/config.toml`。 @@ -223,7 +223,7 @@ docker compose up -d --build 3. 首次启动完成后,检查 `./data` 下自动生成的文件。 -这种方式适合初始化测试;正式部署时,建议先基于 `config.example.toml` 手动生成 `data/config.toml`。 +这种方式适合初始化测试;正式部署时,建议先基于 `data/config.example.toml` 手动生成 `data/config.toml`。 ### 更新部署 diff --git a/src/http/web_server.rs b/src/http/web_server.rs index 0302d81..7322570 100644 --- a/src/http/web_server.rs +++ b/src/http/web_server.rs @@ -205,6 +205,7 @@ struct CreateNetworkRequest { network_code: String, gateway: String, netmask: u8, + secret: String, lease_duration: Option, } @@ -229,7 +230,13 @@ async fn create_network( match state .control_service - .add_network(body.network_code, gateway, body.netmask, lease_duration) + .add_network( + body.network_code, + gateway, + body.netmask, + lease_duration, + body.secret, + ) .await { Ok(()) => ApiResponse::<()>::ok_msg("创建成功").into_response(), @@ -241,6 +248,7 @@ async fn create_network( struct UpdateNetworkRequest { gateway: String, netmask: u8, + secret: String, lease_duration: u64, } @@ -264,7 +272,13 @@ async fn update_network( match state .control_service - .update_network(&network_code, gateway, body.netmask, lease_duration) + .update_network( + &network_code, + gateway, + body.netmask, + lease_duration, + body.secret, + ) .await { Ok(()) => ApiResponse::<()>::ok_msg("更新成功").into_response(), diff --git a/src/main.rs b/src/main.rs index 0ae749a..e35d2e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,7 +107,7 @@ async fn main() { } log::info!( - "Loaded {} configured networks and {} network secrets", + "Loaded {} bootstrap networks and {} bootstrap secrets from config", conf.custom_nets.len(), conf.network_secrets.len() ); diff --git a/src/server/control_server/db.rs b/src/server/control_server/db.rs index ec84ca8..afdc58d 100644 --- a/src/server/control_server/db.rs +++ b/src/server/control_server/db.rs @@ -10,6 +10,10 @@ use std::path::Path; static DB_POOL: OnceCell = OnceCell::new(); const DB_FILE: &str = "network_control.db"; +pub fn db_pool_initialized() -> bool { + DB_POOL.get().is_some() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NetworkSource { Config = 0, @@ -66,6 +70,7 @@ pub struct NetworkRecord { pub network_code: String, pub gateway: String, pub netmask: u8, + pub secret: String, pub lease_duration: i64, pub source: NetworkSource, pub created_at: i64, @@ -74,7 +79,7 @@ pub struct NetworkRecord { impl NetworkRecord { pub fn to_ipv4_net(&self) -> Option { let gateway: Ipv4Addr = self.gateway.parse().ok()?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); + let network_ip = Ipv4Addr::from(u32::from(gateway).checked_sub(1)?); Ipv4Net::new(network_ip, self.netmask).ok() } } @@ -116,6 +121,7 @@ pub async fn init_db_pool() -> anyhow::Result<()> { network_code TEXT PRIMARY KEY, gateway TEXT NOT NULL, netmask INTEGER NOT NULL, + secret TEXT NOT NULL DEFAULT '', lease_duration INTEGER NOT NULL, source INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL @@ -129,6 +135,9 @@ pub async fn init_db_pool() -> anyhow::Result<()> { let _ = sqlx::query("ALTER TABLE networks ADD COLUMN source INTEGER NOT NULL DEFAULT 0") .execute(&pool) .await; + let _ = sqlx::query("ALTER TABLE networks ADD COLUMN secret TEXT NOT NULL DEFAULT ''") + .execute(&pool) + .await; sqlx::query( "CREATE TABLE IF NOT EXISTS devices ( @@ -170,12 +179,13 @@ pub async fn save_network(record: &NetworkRecord) -> anyhow::Result<()> { }; sqlx::query( - r#"INSERT OR REPLACE INTO networks (network_code, gateway, netmask, lease_duration, source, created_at) - VALUES (?, ?, ?, ?, ?, ?)"#, + r#"INSERT OR REPLACE INTO networks (network_code, gateway, netmask, secret, lease_duration, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&record.network_code) .bind(&record.gateway) .bind(record.netmask as i32) + .bind(&record.secret) .bind(record.lease_duration) .bind(record.source as i32) .bind(record.created_at) @@ -192,12 +202,13 @@ pub async fn save_network_if_not_exists(record: &NetworkRecord) -> anyhow::Resul }; let result = sqlx::query( - r#"INSERT OR IGNORE INTO networks (network_code, gateway, netmask, lease_duration, source, created_at) - VALUES (?, ?, ?, ?, ?, ?)"#, + r#"INSERT OR IGNORE INTO networks (network_code, gateway, netmask, secret, lease_duration, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&record.network_code) .bind(&record.gateway) .bind(record.netmask as i32) + .bind(&record.secret) .bind(record.lease_duration) .bind(record.source as i32) .bind(record.created_at) @@ -212,6 +223,7 @@ pub async fn update_network( network_code: &str, gateway: &str, netmask: u8, + secret: &str, lease_duration: i64, ) -> anyhow::Result { let Some(pool) = DB_POOL.get() else { @@ -219,10 +231,11 @@ pub async fn update_network( }; let result = sqlx::query( - r#"UPDATE networks SET gateway = ?, netmask = ?, lease_duration = ? WHERE network_code = ?"#, + r#"UPDATE networks SET gateway = ?, netmask = ?, secret = ?, lease_duration = ? WHERE network_code = ?"#, ) .bind(gateway) .bind(netmask as i32) + .bind(secret) .bind(lease_duration) .bind(network_code) .execute(pool) @@ -253,7 +266,7 @@ pub async fn get_network(network_code: &str) -> anyhow::Result anyhow::Result anyhow::Result> { }; let records: Vec = sqlx::query( - r#"SELECT network_code, gateway, netmask, lease_duration, source, created_at FROM networks ORDER BY created_at"#, + r#"SELECT network_code, gateway, netmask, secret, lease_duration, source, created_at FROM networks ORDER BY created_at"#, ) .fetch(pool) .try_filter_map(|row| async move { @@ -293,6 +307,7 @@ pub async fn load_all_networks() -> anyhow::Result> { network_code: row.try_get("network_code")?, gateway: row.try_get("gateway")?, netmask: netmask as u8, + secret: row.try_get("secret")?, lease_duration: row.try_get("lease_duration")?, source: NetworkSource::from_i32(source), created_at: row.try_get("created_at")?, diff --git a/src/server/control_server/service.rs b/src/server/control_server/service.rs index 57dba5e..e5dc14e 100644 --- a/src/server/control_server/service.rs +++ b/src/server/control_server/service.rs @@ -2,6 +2,9 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode}; use crate::server::control_server::db; use crate::server::control_server::db::{NetworkRecord, NetworkSource}; use crate::server::network_state_provider::{i64_to_system_time, NetworkState, NetworkStateProvider}; +use crate::utils::config::{ + DEFAULT_NETWORK_CODE, validate_network_code_value, validate_network_secret_value, +}; use anyhow::{Context, bail}; use bytes::Bytes; use dashmap::DashMap; @@ -31,11 +34,16 @@ pub struct NetworkConfig { pub source: NetworkSource, } +#[derive(Clone)] +struct ManagedNetwork { + config: NetworkConfig, + secret: String, +} + #[derive(Clone)] pub struct ControlService { default_lease_duration: Duration, - db_nets: Arc>>, - network_secrets: Arc>, + db_nets: Arc>>, network_state_provider: NetworkStateProvider, network_init_locks: Arc>>>, peer_manager: Arc>>>, @@ -48,14 +56,12 @@ impl ControlService { lease_duration: Duration, ) -> Self { let network_states = Arc::new(DashMap::new()); - - Self::save_config_networks_to_db(&custom_nets, lease_duration).await; - let db_nets = Self::load_networks_from_db().await; + let db_nets = + Self::load_or_initialize_networks(custom_nets, network_secrets, lease_duration).await; let service = Self { default_lease_duration: lease_duration, db_nets: Arc::new(RwLock::new(db_nets)), - network_secrets: Arc::new(network_secrets), network_state_provider: NetworkStateProvider::new(network_states), network_init_locks: Arc::new(DashMap::new()), peer_manager: Arc::new(RwLock::new(None)), @@ -70,7 +76,64 @@ impl ControlService { service } - async fn save_config_networks_to_db(custom_nets: &HashMap, lease_duration: Duration) { + async fn load_or_initialize_networks( + custom_nets: HashMap, + network_secrets: HashMap, + lease_duration: Duration, + ) -> HashMap { + if !db::db_pool_initialized() { + return Self::build_networks_from_config(custom_nets, network_secrets, lease_duration); + } + + let mut records = Self::load_network_records_from_db().await; + if records.is_empty() { + Self::seed_config_networks_to_db(&custom_nets, &network_secrets, lease_duration).await; + records = Self::load_network_records_from_db().await; + } else if Self::backfill_missing_secrets_from_config(&records, &network_secrets).await { + records = Self::load_network_records_from_db().await; + } + + if records.is_empty() { + log::warn!("No networks found in DB after initialization, falling back to config"); + return Self::build_networks_from_config(custom_nets, network_secrets, lease_duration); + } + + Self::managed_networks_from_records(records) + } + + fn build_networks_from_config( + custom_nets: HashMap, + network_secrets: HashMap, + lease_duration: Duration, + ) -> HashMap { + custom_nets + .into_iter() + .filter_map(|(code, net)| { + let Some(secret) = network_secrets.get(&code).cloned() else { + log::error!("Missing secret for network '{}' while building config fallback", code); + return None; + }; + + Some(( + code, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: NetworkSource::Config, + }, + secret, + }, + )) + }) + .collect() + } + + async fn seed_config_networks_to_db( + custom_nets: &HashMap, + network_secrets: &HashMap, + lease_duration: Duration, + ) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -78,11 +141,16 @@ impl ControlService { let lease_secs = lease_duration.as_secs() as i64; for (code, net) in custom_nets { + let Some(secret) = network_secrets.get(code) else { + log::error!("Skip seeding network '{}' because its secret is missing", code); + continue; + }; let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); let record = NetworkRecord { network_code: code.clone(), gateway: gateway.to_string(), netmask: net.prefix_len(), + secret: secret.clone(), lease_duration: lease_secs, source: NetworkSource::Config, created_at: now, @@ -95,27 +163,79 @@ impl ControlService { } } - async fn load_networks_from_db() -> HashMap { - let mut nets = HashMap::new(); - match db::load_all_networks().await { - Ok(records) => { - for record in records { - if let Some(net) = record.to_ipv4_net() { - nets.insert( - record.network_code, - NetworkConfig { - net, - lease_duration: Duration::from_secs(record.lease_duration as u64), - source: record.source, - }, - ); - } + async fn backfill_missing_secrets_from_config( + records: &[NetworkRecord], + network_secrets: &HashMap, + ) -> bool { + let mut updated = false; + + for record in records { + if !record.secret.trim().is_empty() { + continue; + } + + let Some(secret) = network_secrets.get(&record.network_code) else { + continue; + }; + + let mut updated_record = record.clone(); + updated_record.secret = secret.clone(); + + match db::save_network(&updated_record).await { + Ok(()) => { + updated = true; + log::info!( + "Backfilled missing secret for network '{}' from config", + record.network_code + ); + } + Err(e) => { + log::error!( + "Failed to backfill secret for network '{}': {}", + record.network_code, + e + ); } } + } + + updated + } + + async fn load_network_records_from_db() -> Vec { + match db::load_all_networks().await { + Ok(records) => records, Err(e) => { log::error!("Failed to load networks from DB: {}", e); + Vec::new() + } + } + } + + fn managed_networks_from_records(records: Vec) -> HashMap { + let mut nets = HashMap::new(); + + for record in records { + if let Some(net) = record.to_ipv4_net() { + nets.insert( + record.network_code, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration: Duration::from_secs(record.lease_duration as u64), + source: record.source, + }, + secret: record.secret, + }, + ); + } else { + log::error!( + "Skip network '{}' because gateway/netmask in DB is invalid", + record.network_code + ); } } + nets } @@ -182,17 +302,26 @@ impl ControlService { self.db_nets .read() .get(network_code) - .copied() + .map(|network| network.config) .ok_or_else(|| anyhow::anyhow!("network_code '{}' is not allowed", network_code)) } + fn build_network_from_gateway(gateway: Ipv4Addr, netmask: u8) -> anyhow::Result { + let network_ip = u32::from(gateway) + .checked_sub(1) + .context("gateway must be the first usable IP in the subnet")?; + Ipv4Net::new(Ipv4Addr::from(network_ip), netmask).context("Invalid network") + } + fn validate_registration(&self, reg_req: &RegRequestMsg) -> anyhow::Result<()> { - let Some(expected_secret) = self.network_secrets.get(®_req.network_code) else { + let networks = self.db_nets.read(); + let Some(network) = networks.get(®_req.network_code) else { bail!( - "network_code '{}' is not allowed by server configuration", + "network_code '{}' is not allowed by server configuration or database", reg_req.network_code ); }; + validate_network_secret_value(®_req.network_code, &network.secret)?; let Some(provided_secret) = reg_req.key_sign.as_deref() else { bail!( @@ -201,7 +330,7 @@ impl ControlService { ); }; - if provided_secret != expected_secret { + if provided_secret != network.secret { bail!( "invalid network secret for network_code '{}'", reg_req.network_code @@ -211,16 +340,6 @@ impl ControlService { Ok(()) } - fn ensure_network_secret_configured(&self, network_code: &str) -> anyhow::Result<()> { - if !self.network_secrets.contains_key(network_code) { - bail!( - "network_code '{}' has no configured secret. Add it under [network_secrets] and restart the server", - network_code - ); - } - Ok(()) - } - /// DCL: 获取或创建 NetworkState async fn get_or_create_network_state( &self, @@ -374,14 +493,20 @@ impl ControlService { gateway: Ipv4Addr, netmask: u8, lease_duration: Option, + secret: String, ) -> anyhow::Result<()> { - self.ensure_network_secret_configured(&network_code)?; + validate_network_code_value(&network_code, "network_code")?; + validate_network_secret_value(&network_code, &secret)?; + if self.db_nets.read().contains_key(&network_code) { + bail!("network_code '{}' already exists", network_code); + } if self.db_nets.read().contains_key(&network_code) { bail!("网络编号 '{}' 已存在", network_code); } let lease_duration = lease_duration.unwrap_or(self.default_lease_duration); + let net = Self::build_network_from_gateway(gateway, netmask)?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -391,6 +516,7 @@ impl ControlService { network_code: network_code.clone(), gateway: gateway.to_string(), netmask, + secret: secret.clone(), lease_duration: lease_duration.as_secs() as i64, source: NetworkSource::Manual, created_at: now, @@ -398,14 +524,15 @@ impl ControlService { db::save_network(&record).await?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); - let net = Ipv4Net::new(network_ip, netmask).context("Invalid network")?; self.db_nets.write().insert( network_code, - NetworkConfig { - net, - lease_duration, - source: NetworkSource::Manual, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: NetworkSource::Manual, + }, + secret, }, ); @@ -418,12 +545,14 @@ impl ControlService { gateway: Ipv4Addr, netmask: u8, lease_duration: Duration, + secret: String, ) -> anyhow::Result<()> { + validate_network_secret_value(network_code, &secret)?; let original_source = self .db_nets .read() .get(network_code) - .map(|c| c.source) + .map(|c| c.config.source) .ok_or_else(|| anyhow::anyhow!("网络编号 '{}' 不存在", network_code))?; if db::network_has_devices(network_code).await? { @@ -441,18 +570,21 @@ impl ControlService { network_code, &gateway.to_string(), netmask, + &secret, lease_duration.as_secs() as i64, ) .await?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); - let net = Ipv4Net::new(network_ip, netmask).context("Invalid network")?; + let net = Self::build_network_from_gateway(gateway, netmask)?; self.db_nets.write().insert( network_code.to_string(), - NetworkConfig { - net, - lease_duration, - source: original_source, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: original_source, + }, + secret, }, ); @@ -462,6 +594,10 @@ impl ControlService { } pub async fn delete_network(&self, network_code: &str) -> anyhow::Result<()> { + if network_code == DEFAULT_NETWORK_CODE { + bail!("the default network cannot be deleted"); + } + if !self.db_nets.read().contains_key(network_code) { bail!("网络编号 '{}' 不存在", network_code); } @@ -505,7 +641,9 @@ impl ControlService { impl ControlService { pub fn get_network_codes(&self) -> Vec { - self.db_nets.read().keys().cloned().collect() + let mut codes: Vec = self.db_nets.read().keys().cloned().collect(); + codes.sort(); + codes } pub fn get_network_state(&self, network_code: &str) -> Option> { @@ -527,10 +665,10 @@ impl ControlService { pub fn get_network_info(&self) -> Vec { let db_nets = self.db_nets.read(); - db_nets + let mut networks: Vec = db_nets .iter() - .map(|(code, config)| { - let gateway = Ipv4Addr::from(u32::from(config.net.network()) + 1); + .map(|(code, network)| { + let gateway = Ipv4Addr::from(u32::from(network.config.net.network()) + 1); let (all_count, online_count) = self .network_state_provider .get(code) @@ -540,15 +678,20 @@ impl ControlService { NetworkInfoVO { network_code: code.clone(), gateway, - netmask: config.net.prefix_len(), - net: config.net, - lease_duration: config.lease_duration.as_secs(), - source: config.source, + netmask: network.config.net.prefix_len(), + net: network.config.net, + secret: network.secret.clone(), + lease_duration: network.config.lease_duration.as_secs(), + source: network.config.source, + is_default: code == DEFAULT_NETWORK_CODE, + can_delete: code != DEFAULT_NETWORK_CODE, all_count, online_count, } }) - .collect() + .collect(); + networks.sort_by(|a, b| a.network_code.cmp(&b.network_code)); + networks } pub async fn get_device_info(&self, network_code: &str) -> Option> { @@ -667,8 +810,11 @@ pub struct NetworkInfoVO { pub gateway: Ipv4Addr, pub netmask: u8, pub net: Ipv4Net, + pub secret: String, pub lease_duration: u64, pub source: NetworkSource, + pub is_default: bool, + pub can_delete: bool, pub all_count: u32, pub online_count: u32, } diff --git a/src/utils/config.rs b/src/utils/config.rs index 6c01bef..081f012 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -148,16 +148,16 @@ impl ConfigFile { } for code in self.custom_nets.keys() { - validate_network_code(code, "custom_nets")?; + validate_network_code_value(code, "custom_nets")?; } for code in self.white_list.iter() { - validate_network_code(code, "white_list")?; + validate_network_code_value(code, "white_list")?; } for (code, secret) in self.network_secrets.iter() { - validate_network_code(code, "network_secrets")?; - validate_network_secret(code, secret)?; + validate_network_code_value(code, "network_secrets")?; + validate_network_secret_value(code, secret)?; } for code in self.custom_nets.keys() { @@ -167,14 +167,14 @@ impl ConfigFile { code ); }; - validate_network_secret(code, secret)?; + validate_network_secret_value(code, secret)?; } Ok(()) } } -fn validate_network_code(code: &str, field_name: &str) -> anyhow::Result<()> { +pub fn validate_network_code_value(code: &str, field_name: &str) -> anyhow::Result<()> { if code.trim().is_empty() { bail!("{field_name} contains an empty network_code"); } @@ -187,7 +187,7 @@ fn validate_network_code(code: &str, field_name: &str) -> anyhow::Result<()> { Ok(()) } -fn validate_network_secret(network_code: &str, secret: &str) -> anyhow::Result<()> { +pub fn validate_network_secret_value(network_code: &str, secret: &str) -> anyhow::Result<()> { if secret.len() < 24 { bail!( "Secret for network_code '{}' is too short. Use at least 24 characters", @@ -250,14 +250,14 @@ persistence = true # peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] # server_token = "your-secret-token" -# Every allowed network_code must be declared here. -# Clients must explicitly use network_code = "default" to join the default network. +# Bootstrap networks. When persistence is enabled and the database is empty, +# these entries are imported into the DB on first start. [custom_nets] default = "10.26.0.0/24" # office = "10.25.0.0/24" # dev = "10.27.1.0/24" -# Every configured network_code must have a strong secret here. +# Bootstrap secrets for the first import. [network_secrets] default = "Use-A-Long-Strong-Secret-At-Least-24-Chars!" # office = "Replace-With-A-Different-Long-Strong-Secret!" diff --git a/static/index.html b/static/index.html index 136b2f7..f58f8da 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,7 @@ - IoT 控制中心 + VNTS Network Console @@ -33,7 +33,7 @@
- IoT Control Center + VNTS Network Console
+
+
+ + Network Secret + + +
+ {{ net.secret || '(empty)' }} +
点击查看设备 @@ -446,6 +466,13 @@

{{ isEditMode ? '编辑网络' : '新增网 class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="如: 24" required>

+
+ + +

每个 network_code 都必须使用长强密钥。

+
添加服务器 // 网络表单 const showNetworkModal = ref(false); const isEditMode = ref(false); - const networkForm = ref({ network_code: '', gateway: '', netmask: 24, lease_duration: null }); + const networkForm = ref({ network_code: '', gateway: '', netmask: 24, secret: '', lease_duration: null }); + const copiedSecretCode = ref(''); // 确认删除 const showConfirmModal = ref(false); @@ -601,12 +629,13 @@

添加服务器

loading.value = true; modalErrorMsg.value = ''; errorMsg.value = ''; + const isLoginRequest = endpoint === '/login'; const headers = { 'Content-Type': 'application/json', ...options.headers }; if (token.value) headers['Authorization'] = `Bearer ${token.value}`; try { const res = await fetch(`${API_BASE}${endpoint}`, { ...options, headers }); const json = await res.json(); - if (res.status === 401 || json.code === 401) { + if (!isLoginRequest && (res.status === 401 || json.code === 401)) { logout(); errorMsg.value = '登录已过期'; return null; @@ -685,7 +714,7 @@

添加服务器

localStorage.setItem('jwt_token', data.token); localStorage.setItem('username', loginForm.value.username); username.value = loginForm.value.username; - fetchNetworks(); + handleRouteChange(); } }; @@ -710,6 +739,21 @@

添加服务器

if (data) peerServers.value = data; }; + const copySecret = async (networkCode, secret) => { + if (!secret) return; + try { + await navigator.clipboard.writeText(secret); + copiedSecretCode.value = networkCode; + setTimeout(() => { + if (copiedSecretCode.value === networkCode) { + copiedSecretCode.value = ''; + } + }, 1500); + } catch (e) { + modalErrorMsg.value = '复制 secret 失败'; + } + }; + // 停止设备自动刷新 const stopDeviceRefresh = () => { if (deviceRefreshTimer) { @@ -832,7 +876,7 @@

添加服务器

// 网络管理 const openAddNetworkModal = () => { isEditMode.value = false; - networkForm.value = { network_code: '', gateway: '', netmask: 24, lease_duration: null }; + networkForm.value = { network_code: '', gateway: '', netmask: 24, secret: '', lease_duration: null }; modalErrorMsg.value = ''; showNetworkModal.value = true; }; @@ -843,6 +887,7 @@

添加服务器

network_code: net.network_code, gateway: net.gateway, netmask: net.netmask, + secret: net.secret || '', lease_duration: net.lease_duration }; modalErrorMsg.value = ''; @@ -862,6 +907,7 @@

添加服务器

body: JSON.stringify({ gateway: form.gateway, netmask: form.netmask, + secret: form.secret, lease_duration: form.lease_duration }) }); @@ -869,7 +915,8 @@

添加服务器

const body = { network_code: form.network_code, gateway: form.gateway, - netmask: form.netmask + netmask: form.netmask, + secret: form.secret }; if (form.lease_duration) body.lease_duration = form.lease_duration; result = await request('/networks', { method: 'POST', body: JSON.stringify(body) }); @@ -882,6 +929,10 @@

添加服务器

// 删除确认 const confirmDeleteNetwork = (net) => { + if (!net.can_delete) { + modalErrorMsg.value = '默认 network 不允许删除'; + return; + } deleteType.value = 'network'; deleteTarget.value = net; confirmMessage.value = `确定要删除网络 "${net.network_code}" 吗?此操作不可撤销。`; @@ -1174,9 +1225,9 @@

添加服务器

}; onMounted(() => { + window.addEventListener('hashchange', handleRouteChange); if (isLoggedIn.value) { handleRouteChange(); - window.addEventListener('hashchange', handleRouteChange); } }); @@ -1198,6 +1249,8 @@

添加服务器

networks, networkSearch, filteredNetworks, + copiedSecretCode, + copySecret, selectNetwork, devices, deviceSearch, From 1967550ba5014cecb5fcbc611a983eb08907a14f Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sun, 19 Apr 2026 09:15:32 +0800 Subject: [PATCH 6/7] refactor: improve code formatting and organization - Adjust indentation and line breaks for better readability - Reorganize imports and function parameters for consistency - Ensure consistent use of method chaining and formatting These changes enhance the overall code quality and maintainability without altering any functionality. The refactoring focuses on improving the visual structure of the code, making it easier to navigate and understand. --- build.rs | 15 ++++-- src/http/web_server.rs | 10 ++-- src/main.rs | 26 +++++---- src/protocol/control_message.rs | 2 - src/protocol/server_message.rs | 2 +- src/server/control_server/db.rs | 5 +- src/server/control_server/handler.rs | 14 +++-- src/server/control_server/service.rs | 66 +++++++++++++++-------- src/server/mod.rs | 4 +- src/server/network_state_provider.rs | 80 ++++++++++++++++++++-------- 10 files changed, 148 insertions(+), 76 deletions(-) diff --git a/build.rs b/build.rs index 7e046be..531c6c3 100644 --- a/build.rs +++ b/build.rs @@ -2,9 +2,14 @@ fn main() { let mut config = prost_build::Config::new(); config.protoc_arg("--experimental_allow_proto3_optional"); - config.compile_protos( - &["proto/control_message.proto", "proto/rpc.proto", "proto/server_message.proto"], - &["proto"], - ) - .unwrap(); + config + .compile_protos( + &[ + "proto/control_message.proto", + "proto/rpc.proto", + "proto/server_message.proto", + ], + &["proto"], + ) + .unwrap(); } diff --git a/src/http/web_server.rs b/src/http/web_server.rs index 7322570..d318954 100644 --- a/src/http/web_server.rs +++ b/src/http/web_server.rs @@ -1,13 +1,13 @@ use crate::ControlService; use crate::server::control_server::service::{DeviceInfoVO, NetworkInfoVO}; use axum::{ + Json, Router, body::Body, extract::{Path, Query, State}, - http::{header, HeaderMap, Request, StatusCode, Uri}, + http::{HeaderMap, Request, StatusCode, Uri, header}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get, post, put}, - Json, Router, }; use jsonwebtoken::{DecodingKey, EncodingKey, Validation}; use mime_guess::from_path; @@ -224,9 +224,7 @@ async fn create_network( return ApiResponse::<()>::err("无效的掩码").into_response(); } - let lease_duration = body - .lease_duration - .map(std::time::Duration::from_secs); + let lease_duration = body.lease_duration.map(std::time::Duration::from_secs); match state .control_service @@ -406,7 +404,7 @@ async fn static_handler(uri: Uri) -> impl IntoResponse { [(header::CONTENT_TYPE, mime.as_ref())], Body::from(content.data), ) - .into_response(); + .into_response(); } (StatusCode::NOT_FOUND, "404 Not Found").into_response() diff --git a/src/main.rs b/src/main.rs index e35d2e3..626e60b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,21 +145,29 @@ struct PeerConf { } async fn init_peer_manager(conf: &PeerConf, control_service: &ControlService) { - let server_token = conf.server_token.clone().unwrap_or_else(|| "default_token".to_string()); + let server_token = conf + .server_token + .clone() + .unwrap_or_else(|| "default_token".to_string()); let network_state_provider = control_service.get_network_state_provider().clone(); let peer_manager = Arc::new(PeerServerManager::new(server_token, network_state_provider)); control_service.set_peer_manager(peer_manager.clone()); if let Some(server_quic_bind) = conf.server_quic_bind { - let (certs, key) = match crate::utils::cert::get_cert_and_key(conf.cert.clone(), conf.key.clone()) { - Ok((certs, key)) => (certs, key), - Err(e) => { - log::error!("Failed to load cert/key for peer server: {:?}", e); - panic!("{:?}", e) - } - }; - if let Err(e) = peer_manager.clone().start_server(server_quic_bind, certs, key).await { + let (certs, key) = + match crate::utils::cert::get_cert_and_key(conf.cert.clone(), conf.key.clone()) { + Ok((certs, key)) => (certs, key), + Err(e) => { + log::error!("Failed to load cert/key for peer server: {:?}", e); + panic!("{:?}", e) + } + }; + if let Err(e) = peer_manager + .clone() + .start_server(server_quic_bind, certs, key) + .await + { log::error!("Failed to start peer server: {:?}", e); } else { log::info!("Peer server started on {}", server_quic_bind); diff --git a/src/protocol/control_message.rs b/src/protocol/control_message.rs index 5c5646f..24b93e2 100644 --- a/src/protocol/control_message.rs +++ b/src/protocol/control_message.rs @@ -108,7 +108,6 @@ pub struct RegResponseMsg { pub server_version: String, } impl RegResponseMsg { - pub fn to(self) -> proto::RegResponseMsg { proto::RegResponseMsg { ip: self.ip.into(), @@ -201,7 +200,6 @@ pub enum ResponseMessage { ConfirmReg(ConfirmRegResponseMsg), } impl ResponseMessage { - pub fn encode(self) -> BytesMut { let response_payload = match self { ResponseMessage::Reg(reg) => ResponsePayload::Reg(reg.to()), diff --git a/src/protocol/server_message.rs b/src/protocol/server_message.rs index 795a9f5..ab9e3b2 100644 --- a/src/protocol/server_message.rs +++ b/src/protocol/server_message.rs @@ -2,5 +2,5 @@ mod proto { include!(concat!(env!("OUT_DIR"), "/protocol.server_message.rs")); } -pub use proto::*; pub use proto::server_message::Payload; +pub use proto::*; diff --git a/src/server/control_server/db.rs b/src/server/control_server/db.rs index afdc58d..a72b537 100644 --- a/src/server/control_server/db.rs +++ b/src/server/control_server/db.rs @@ -383,7 +383,10 @@ pub async fn release_device_ip(network_code: &str, device_id: &str) -> anyhow::R } #[allow(dead_code)] -pub async fn get_device(network_code: &str, device_id: &str) -> anyhow::Result> { +pub async fn get_device( + network_code: &str, + device_id: &str, +) -> anyhow::Result> { let Some(pool) = DB_POOL.get() else { return Ok(None); }; diff --git a/src/server/control_server/handler.rs b/src/server/control_server/handler.rs index 8fafcbd..c32d68e 100644 --- a/src/server/control_server/handler.rs +++ b/src/server/control_server/handler.rs @@ -1,6 +1,6 @@ use crate::protocol::control_message::{ - ClientSimpleInfoList, ConfirmRegResponseMsg, ErrorResponseMsg, RegResponseMsg, RequestMessage, ResponseMessage, - SelectiveBroadcast, + ClientSimpleInfoList, ConfirmRegResponseMsg, ErrorResponseMsg, RegResponseMsg, RequestMessage, + ResponseMessage, SelectiveBroadcast, }; use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, MsgType, NetPacket}; use crate::protocol::rpc_message::rpc_message_request::RpcReqPayload; @@ -100,7 +100,11 @@ impl ControlHandler { bail!("Session is not in pending confirmation state"); } - if let Err(e) = session.network_state.confirm_registration(&session.network_code, &session.device_id).await { + if let Err(e) = session + .network_state + .confirm_registration(&session.network_code, &session.device_id) + .await + { log::error!("Failed to save confirmed device: {:?}", e); let msg_response = ErrorResponseMsg { code: 500, @@ -242,7 +246,9 @@ impl ControlHandler { let network_code = session.network_code.clone(); let data = buf.freeze(); - let forwarded = peer_manager.forward_with_best_route(&network_code, dest, data.clone()).await; + let forwarded = peer_manager + .forward_with_best_route(&network_code, dest, data.clone()) + .await; if !forwarded { if let Some(sender) = session.network_state.sender_map().get(&dest) { diff --git a/src/server/control_server/service.rs b/src/server/control_server/service.rs index e5dc14e..8b205dc 100644 --- a/src/server/control_server/service.rs +++ b/src/server/control_server/service.rs @@ -1,7 +1,9 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode}; use crate::server::control_server::db; use crate::server::control_server::db::{NetworkRecord, NetworkSource}; -use crate::server::network_state_provider::{i64_to_system_time, NetworkState, NetworkStateProvider}; +use crate::server::network_state_provider::{ + NetworkState, NetworkStateProvider, i64_to_system_time, +}; use crate::utils::config::{ DEFAULT_NETWORK_CODE, validate_network_code_value, validate_network_secret_value, }; @@ -110,7 +112,10 @@ impl ControlService { .into_iter() .filter_map(|(code, net)| { let Some(secret) = network_secrets.get(&code).cloned() else { - log::error!("Missing secret for network '{}' while building config fallback", code); + log::error!( + "Missing secret for network '{}' while building config fallback", + code + ); return None; }; @@ -142,7 +147,10 @@ impl ControlService { for (code, net) in custom_nets { let Some(secret) = network_secrets.get(code) else { - log::error!("Skip seeding network '{}' because its secret is missing", code); + log::error!( + "Skip seeding network '{}' because its secret is missing", + code + ); continue; }; let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); @@ -212,7 +220,9 @@ impl ControlService { } } - fn managed_networks_from_records(records: Vec) -> HashMap { + fn managed_networks_from_records( + records: Vec, + ) -> HashMap { let mut nets = HashMap::new(); for record in records { @@ -258,13 +268,14 @@ impl ControlService { let random_id = rand::rng().next_u64(); let device_id = reg_req.device_id.clone(); - let (ip, _old_ip, entry) = match state.allocate_ip_and_get_entry(reg_req, random_id, sender) { - Ok(rs) => rs, - Err(e) => { - log::warn!("network_code={network_code},device_id={device_id},e={e:?}"); - return Err(e); - } - }; + let (ip, _old_ip, entry) = + match state.allocate_ip_and_get_entry(reg_req, random_id, sender) { + Ok(rs) => rs, + Err(e) => { + log::warn!("network_code={network_code},device_id={device_id},e={e:?}"); + return Err(e); + } + }; ( Session { @@ -384,7 +395,10 @@ impl ControlService { .map(|v| v.key().clone()) .collect(); for network_code in keys { - let option = self.network_state_provider.get(&network_code).map(|v| v.clone()); + let option = self + .network_state_provider + .get(&network_code) + .map(|v| v.clone()); if let Some(state) = option { if !state.is_empty() { continue; @@ -621,11 +635,7 @@ impl ControlService { Ok(()) } - pub async fn delete_device( - &self, - network_code: &str, - device_id: &str, - ) -> anyhow::Result<()> { + pub async fn delete_device(&self, network_code: &str, device_id: &str) -> anyhow::Result<()> { if let Some(state) = self.network_state_provider.get(network_code) { if state.is_device_online(device_id) { bail!("设备在线,无法删除"); @@ -647,7 +657,9 @@ impl ControlService { } pub fn get_network_state(&self, network_code: &str) -> Option> { - self.network_state_provider.get(network_code).map(|s| s.clone()) + self.network_state_provider + .get(network_code) + .map(|s| s.clone()) } pub fn set_peer_manager(&self, manager: Arc) { @@ -658,7 +670,6 @@ impl ControlService { self.peer_manager.read().clone() } - pub fn get_network_state_provider(&self) -> &NetworkStateProvider { &self.network_state_provider } @@ -700,7 +711,8 @@ impl ControlService { } else { match db::load_all_devices(network_code).await { Ok(records) => { - let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + let format = + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); records .into_iter() .map(|r| { @@ -712,7 +724,9 @@ impl ControlService { device_version: r.device_version, ip: r.ip.as_ref().and_then(|s| s.parse().ok()), status: "Offline".to_string(), - last_connect_time: last_connect_time.format(&format).unwrap_or_default(), + last_connect_time: last_connect_time + .format(&format) + .unwrap_or_default(), disconnect_time: None, latency_ms: None, server_addr: None, @@ -767,7 +781,9 @@ impl Drop for Session { fn drop(&mut self) { match self.registration_status { RegistrationStatus::Confirmed => { - let record = self.network_state.offline_ip(&self.device_id, self.ip, self.random_id); + let record = + self.network_state + .offline_ip(&self.device_id, self.ip, self.random_id); if let Some(record) = record { tokio::spawn(async move { if let Err(e) = db::save_or_update_device(&record).await { @@ -783,7 +799,11 @@ impl Drop for Session { self.device_id, self.ip ); - self.network_state.release_pre_registered_ip(&self.device_id, self.ip, self.random_id); + self.network_state.release_pre_registered_ip( + &self.device_id, + self.ip, + self.random_id, + ); } } } diff --git a/src/server/mod.rs b/src/server/mod.rs index 0dbab5b..6eced94 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3,10 +3,10 @@ pub mod network_state_provider; pub mod peer_server; // 传输层 -pub mod tcp; pub mod quic; -pub mod websocket; +pub mod tcp; pub mod tcp_websocket; +pub mod websocket; use crate::server::control_server::service::ControlService; use crate::server::quic::QuicConfig; diff --git a/src/server/network_state_provider.rs b/src/server/network_state_provider.rs index 2abf0be..d7c5eb7 100644 --- a/src/server/network_state_provider.rs +++ b/src/server/network_state_provider.rs @@ -189,7 +189,12 @@ impl NetworkState { } /// 设备离线,返回需要持久化的记录。random_id 用于判断是否为当前会话。 - pub fn offline_ip(&self, device_id: &String, ip: Ipv4Addr, random_id: u64) -> Option { + pub fn offline_ip( + &self, + device_id: &String, + ip: Ipv4Addr, + random_id: u64, + ) -> Option { let mut guard = self.lease_state.lock(); let (success, record) = guard.offline_ip(&self.network_code, device_id, ip, random_id); if success { @@ -264,7 +269,11 @@ impl NetworkState { if should_remove { self.sender_map.remove(&ip); self.traffic_stats_map.remove(&ip); - log::info!("Released pre-registered IP device_id={}, ip={}", device_id, ip); + log::info!( + "Released pre-registered IP device_id={}, ip={}", + device_id, + ip + ); } } @@ -275,12 +284,18 @@ impl NetworkState { pub fn get_device_entry_by_ip(&self, ip: Ipv4Addr) -> Option { let guard = self.lease_state.lock(); - guard.device_ip_map.get(&ip) + guard + .device_ip_map + .get(&ip) .and_then(|device_id| guard.device_map.get(device_id).cloned()) } /// 同步保存到 DB 后再确认,防止 Drop 时状态不一致 - pub async fn confirm_registration(&self, network_code: &str, device_id: &str) -> anyhow::Result<()> { + pub async fn confirm_registration( + &self, + network_code: &str, + device_id: &str, + ) -> anyhow::Result<()> { if let Some(entry) = self.get_device_entry(device_id) { let record = entry.to_record(network_code); db::save_or_update_device(&record).await?; @@ -296,7 +311,8 @@ impl NetworkState { sender: Sender, ) -> anyhow::Result<(Ipv4Addr, Option, Option)> { let mut guard = self.lease_state.lock(); - let (ip, old_ip) = guard.allocate_ip(&self.net, self.gateway, reg_req.clone(), random_id)?; + let (ip, old_ip) = + guard.allocate_ip(&self.net, self.gateway, reg_req.clone(), random_id)?; if let Some(old_ip) = old_ip { self.sender_map.remove(&old_ip); @@ -307,7 +323,8 @@ impl NetworkState { self.sender_map.insert(ip, sender); if let Some(entry) = &entry { - self.traffic_stats_map.insert(ip, entry.traffic_stats.clone()); + self.traffic_stats_map + .insert(ip, entry.traffic_stats.clone()); } Ok((ip, old_ip, entry)) @@ -330,14 +347,18 @@ impl NetworkState { pub fn get_all_device_simple_info(&self) -> Vec<(String, Option, bool, u64)> { let guard = self.lease_state.lock(); - guard.device_map.values().map(|entry| { - ( - entry.device_id.clone(), - entry.ip, - entry.is_connected, - entry.data_version, - ) - }).collect() + guard + .device_map + .values() + .map(|entry| { + ( + entry.device_id.clone(), + entry.ip, + entry.is_connected, + entry.data_version, + ) + }) + .collect() } pub fn is_empty(&self) -> bool { @@ -354,8 +375,8 @@ impl NetworkState { } pub fn get_device_infos(&self) -> Vec { - use time::macros::format_description; use time::OffsetDateTime; + use time::macros::format_description; let guard = self.lease_state.lock(); let mut list = Vec::new(); @@ -431,7 +452,10 @@ impl NetworkState { }) } - pub fn client_info_list(&self, exclude_ip: Ipv4Addr) -> Vec { + pub fn client_info_list( + &self, + exclude_ip: Ipv4Addr, + ) -> Vec { use crate::protocol::rpc_message::ClientInfo; use time::OffsetDateTime; @@ -522,7 +546,8 @@ impl NetworkStateInner { fn add_device(&mut self, device_entry: DeviceEntry) { if let Some(ip) = device_entry.ip { - self.device_ip_map.insert(ip, device_entry.device_id.clone()); + self.device_ip_map + .insert(ip, device_entry.device_id.clone()); } self.device_map .insert(device_entry.device_id.clone(), device_entry); @@ -545,9 +570,10 @@ impl NetworkStateInner { ) -> anyhow::Result<(Ipv4Addr, Option)> { let expect_ip = reg_req.ip; - let existing_device_info = self.device_map.get(®_req.device_id).map(|e| { - (e.ip, expect_ip.is_none() || e.ip == expect_ip) - }); + let existing_device_info = self + .device_map + .get(®_req.device_id) + .map(|e| (e.ip, expect_ip.is_none() || e.ip == expect_ip)); if let Some((current_ip, ip_matches)) = existing_device_info { if ip_matches { @@ -712,9 +738,14 @@ impl NetworkState { } } - pub async fn new_from_db(network_code: String, net: Ipv4Net, lease_duration: Duration) -> NetworkState { + pub async fn new_from_db( + network_code: String, + net: Ipv4Net, + lease_duration: Duration, + ) -> NetworkState { let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); - let initial_inner_state = Self::build_initial_inner_state(&network_code, net, gateway).await; + let initial_inner_state = + Self::build_initial_inner_state(&network_code, net, gateway).await; Self { time: Mutex::new(Instant::now()), network_code, @@ -759,7 +790,10 @@ impl NetworkStateProvider { } pub fn get_network_codes(&self) -> Vec { - self.network_states.iter().map(|entry| entry.key().clone()).collect() + self.network_states + .iter() + .map(|entry| entry.key().clone()) + .collect() } pub fn get_network_state(&self, network_code: &str) -> Option> { From a33734ce4c09505f8cde85bde730f731ae4d6f90 Mon Sep 17 00:00:00 2001 From: Cg8 <5712.cg8@gmail.com> Date: Sun, 19 Apr 2026 09:24:24 +0800 Subject: [PATCH 7/7] feat: add example configuration file - Create a new example configuration file for the application. - Define listener addresses for TCP, QUIC, and WebSocket protocols. - Include optional settings for allow-list, lease duration, and web admin UI. - Provide placeholders for SQLite persistence and bootstrap networks. This commit introduces a sample configuration file that helps users set up the application with default settings. It includes comments for guidance on modifying the configuration as needed. --- data/config.example.toml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 data/config.example.toml diff --git a/data/config.example.toml b/data/config.example.toml new file mode 100644 index 0000000..16e3e0f --- /dev/null +++ b/data/config.example.toml @@ -0,0 +1,40 @@ +# Listener addresses. Remove a line to disable that protocol. +tcp_bind = "0.0.0.0:29872" +quic_bind = "0.0.0.0:29872" +ws_bind = "0.0.0.0:29872" + +# Optional allow-list. Currently not enforced for registration. +white_list = [] + +# Lease duration in seconds. +lease_duration = 86400 + +# Web admin UI/API login. +web_bind = "0.0.0.0:29871" +username = "admin" +password = "Change-This-Web-Password" + +# SQLite persistence. +persistence = true + +# Leave these unset to auto-generate cert.pem and key.pem. +# cert = "cert.pem" +# key = "key.pem" + +# Optional peer-server settings. +# server_quic_bind = "0.0.0.0:29873" +# peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] +# server_token = "your-secret-token" + +# Bootstrap networks. When persistence is enabled and the database is empty, +# these entries are imported into the DB on first start. +[custom_nets] +default = "172.16.57.0/24" +# office = "172.16.58.0/24" +# dev = "172.16.59.0/24" + +# Bootstrap secrets for the first import. +[network_secrets] +default = "Change-This-Default-Secret-To-A-Strong-One-123!" +# office = "Change-This-Office-Secret-To-A-Strong-One-456!" +# dev = "Change-This-Dev-Secret-To-A-Strong-One-789!"