diff --git a/apps/docs/content/docs/cn/agent.mdx b/apps/docs/content/docs/cn/agent.mdx index d4eca7d2..0e2b3ed6 100644 --- a/apps/docs/content/docs/cn/agent.mdx +++ b/apps/docs/content/docs/cn/agent.mdx @@ -157,6 +157,28 @@ SERVERBEE_ENROLLMENT_CODE=YOUR_ONE_TIME_CODE serverbee-agent 当 Agent 能读取稳定的机器标识时,还会在注册请求中携带指纹。相同机器重复注册时会复用原有服务器记录并轮换 token,而不是继续创建新的占位条目。 +### 更正错误的注册码 + +如果安装时把注册码(或 `server_url`)填错了,且 Agent **尚未注册成功**(配置中还没有 `token`),无需重装即可更正。注册码为单次使用,填错或已被消费的码无法重复使用——必要时先到管理面板「设置」页重新生成一个新码,再执行: + +```bash +serverbee config set enrollment_code <新注册码> -y +# 如果 server_url 也填错了: +serverbee config set server_url http://your-server-ip:9527 -y +``` + +`-y` 会顺带重启 Agent,使其立即用新码重新尝试注册。不加 `-y` 时只写入配置但不重启服务,需再执行 `serverbee restart agent`。 + +若不确定 Agent 当前状态,直接重跑 Agent 安装即可,它会重写 `agent.toml`: + +```bash +serverbee install agent --method \ + --server-url http://your-server-ip:9527 \ + --enrollment-code <新注册码> -y +``` + +如果 Agent **已注册成功**(`agent.toml` 中已有 `token`),注册码便不再使用,无需更正;要把该 Agent 接到另一台 Server,只能用新 Server 的新码重新注册。 + ## 配置文件 Agent 默认读取 `/etc/serverbee/agent.toml` 配置文件。 diff --git a/apps/docs/content/docs/cn/configuration.mdx b/apps/docs/content/docs/cn/configuration.mdx index e48400d1..c92654b6 100644 --- a/apps/docs/content/docs/cn/configuration.mdx +++ b/apps/docs/content/docs/cn/configuration.mdx @@ -121,7 +121,7 @@ ServerBee 使用 [figment](https://github.com/SergioBenitez/Figment) 库加载 | `SERVERBEE_DATABASE__PATH` | `serverbee.db` | SQLite 数据库文件路径(相对于 data_dir) | | `SERVERBEE_DATABASE__MAX_CONNECTIONS` | `10` | 数据库连接池最大连接数 | | `SERVERBEE_AUTH__SESSION_TTL` | `86400` | Session 有效期(秒),默认 24 小时 | -| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Cookie 的 Secure 标记,开发环境设为 `false` | +| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Cookie 的 Secure 标记。仅当浏览器通过普通 HTTP 访问 ServerBee 时设为 `false`,例如 IP 直连的快速开始安装 | | `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | 15 分钟内每 IP 最大登录尝试次数 | | `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | 15 分钟内每 IP 最大 Agent 注册次数 | | `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Agent 升级 Release 资产的基础 URL | @@ -211,18 +211,6 @@ session_ttl = 86400 # 默认: 0 max_servers = 0 -# --- 初始管理员 --- -[admin] -# 初始管理员用户名 -# 仅在 users 表为空时生效 -# 默认: "admin" -username = "admin" - -# 初始管理员密码 -# 留空则自动生成随机密码并打印到日志 -# 默认: "" (自动生成) -password = "" - # --- 速率限制 --- [rate_limit] # 登录接口速率限制:每 IP 每分钟最大请求数 diff --git a/apps/docs/content/docs/cn/deployment.mdx b/apps/docs/content/docs/cn/deployment.mdx index 8d4381b1..0c3e02ec 100644 --- a/apps/docs/content/docs/cn/deployment.mdx +++ b/apps/docs/content/docs/cn/deployment.mdx @@ -240,6 +240,38 @@ sudo journalctl -u serverbee-server --since "1 hour ago" ## Nginx 反向代理 +### 访问地址和 Cookie 配置 + +先确定一个对外访问地址,并让浏览器地址、Agent `server_url` 和 Cookie 配置保持一致: + +| 场景 | 对外地址 | `auth.secure_cookie` | Docker 环境变量 | Agent 地址 | +|------|----------|----------------------|-----------------|------------| +| IP 直连,普通 HTTP | `http://203.0.113.10:9527` | `false` | `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://203.0.113.10:9527` | +| 域名 + HTTPS 反向代理 | `https://monitor.example.com` | `true` | `SERVERBEE_AUTH__SECURE_COOKIE=true` 或不设置该变量 | `https://monitor.example.com` | + +使用域名访问时,需要先添加 DNS `A` 或 `AAAA` 记录指向服务器 IP,再用 Nginx、Caddy 或 Traefik 终止 HTTPS,并把请求反向代理到 ServerBee 的 `127.0.0.1:9527`。 + +如果你是通过快速开始脚本安装的 Server,脚本会为了 HTTP 直连写入 `auth.secure_cookie = false`。迁移到 HTTPS 前,请修改 `/etc/serverbee/server.toml`: + +```toml +[auth] +secure_cookie = true +``` + +然后重启 Server: + +```bash +sudo systemctl restart serverbee-server +``` + +如果 ServerBee 已经通过安装脚本部署,也可以使用内置命令自动完成 Caddy HTTPS 配置: + +```bash +sudo serverbee domain setup --domain monitor.example.com --email admin@example.com -y +``` + +该命令会校验域名 DNS 是否解析到当前服务器,安装并配置 Caddy,把 ServerBee 改为只监听 `127.0.0.1:9527`,并把 `auth.secure_cookie` 设置为 `true`。如果 DNS 还没生效,命令会停止并打印需要添加的 `A`/`AAAA` 记录。 + ### 基础配置 ```nginx title="/etc/nginx/sites-available/serverbee" @@ -402,6 +434,10 @@ server_url = "https://monitor.example.com" ServerBee 自身不处理 TLS 终止。所有 HTTPS/WSS 加密由前置的反向代理(Nginx/Caddy)处理。这种架构简化了 Server 的实现,同时也便于统一管理证书。 + +使用 HTTPS 时,请保持 `auth.secure_cookie = true`。设为 `false` 可能仍然可以登录,但会去掉浏览器的 Secure Cookie 保护,不适合生产环境。 + + ## 备份与恢复 ### 备份 diff --git a/apps/docs/content/docs/cn/quick-start.mdx b/apps/docs/content/docs/cn/quick-start.mdx index 8066ae53..28f74339 100644 --- a/apps/docs/content/docs/cn/quick-start.mdx +++ b/apps/docs/content/docs/cn/quick-start.mdx @@ -28,6 +28,8 @@ services: - "9527:9527" volumes: - serverbee-data:/data + environment: + - SERVERBEE_AUTH__SECURE_COOKIE=false restart: unless-stopped volumes: @@ -46,6 +48,34 @@ docker compose up -d 启动完成后,打开浏览器访问 `http://your-server-ip:9527` 即可进入管理面板。 + +上面的 Compose 示例关闭了 Cookie 的 `Secure` 标记,因为快速开始使用的是普通 HTTP。迁移到 HTTPS 后,请设置 `SERVERBEE_AUTH__SECURE_COOKIE=true`。 + + +## 使用 IP 访问还是域名访问 + +ServerBee 可以直接用服务器 IP 访问,也可以通过域名和反向代理访问。需要额外配置的地方取决于浏览器最终使用的是普通 HTTP 还是 HTTPS。 + +| 访问方式 | 浏览器地址 | Server Cookie 配置 | Agent `server_url` | 额外配置 | +|----------|------------|--------------------|--------------------|----------| +| IP 直连,普通 HTTP | `http://your-server-ip:9527` | `auth.secure_cookie = false` 或 `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://your-server-ip:9527` | 防火墙放行 `9527` 端口 | +| 域名 + HTTPS | `https://monitor.example.com` | `auth.secure_cookie = true` 或 `SERVERBEE_AUTH__SECURE_COOKIE=true` | `https://monitor.example.com` | DNS 指向服务器 IP,并在 ServerBee 前面配置 Nginx、Caddy 或 Traefik | + + +如果你先按快速开始使用 HTTP,之后迁移到域名和 HTTPS,请把 `secure_cookie` 改回 `true`,重启 ServerBee,并把已安装 Agent 的 Server 地址更新为新的 `https://` 地址。 + + +如果你已经有域名,并且 DNS `A` 记录已经指向当前服务器,可以让安装脚本自动配置 Caddy HTTPS: + +```bash +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server \ + --domain monitor.example.com \ + --email admin@example.com \ + -y +``` + +脚本会先校验域名是否解析到当前服务器。未解析或解析到其他 IP 时,脚本会停止并提示应添加的 DNS 记录。`--email` 用于 Let's Encrypt 证书通知,可省略。 + ## 方式二:安装脚本 适用于 Linux 服务器,一键完成安装、配置和 systemd 服务注册。 @@ -53,7 +83,7 @@ docker compose up -d **安装 Server:** ```bash -curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server -y ``` 安装脚本会自动完成以下操作: @@ -65,6 +95,14 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl 5. 创建 systemd service unit 6. 启动服务并设置开机自启 +首次启动时,Server 会自动创建管理员账号并随机生成密码,该密码只会在服务日志中打印一次。使用以下命令查看: + +```bash +sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' +``` + +安装脚本会写入 `auth.secure_cookie = false`,保证普通 HTTP 快速开始地址可以在浏览器中正常登录。迁移到 HTTPS 后,请在 `/etc/serverbee/server.toml` 中改回 `true` 并重启 Server。 + **安装 Agent:** 在需要监控的每台服务器上执行: @@ -89,14 +127,21 @@ sudo serverbee uninstall agent # 卸载 Agent sudo serverbee uninstall server --purge # 卸载服务端并清除数据 ``` +验证 Server 是否运行: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-server -n 80 --no-pager +``` + ## 首次登录 1. 打开浏览器访问 `http://your-server-ip:9527` -2. 使用默认账户 `admin` 和你设置的密码登录 -3. 如果未设置密码,查看 Server 启动日志获取自动生成的密码 +2. 使用默认账户 `admin` 和 Server 启动日志中的随机密码登录 +3. 首次登录时按页面提示完成强制密码修改,可同时选择新的用户名 -首次登录后请立即修改默认密码。进入「设置」页面即可修改。 +自动生成的密码只会在日志中显示一次。请在日志轮转前复制,并在暴露到公网前完成首次密码修改。 ## 添加第一台 Agent @@ -121,6 +166,13 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl 脚本会自动检测平台架构、下载对应二进制、写入配置、注册 systemd 服务并启动 Agent。 +验证 Agent 是否已连接并开始上报: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-agent -n 80 --no-pager +``` + 更多采集、日志等可调选项请参考 [Agent 配置](/cn/docs/agent)和[完整配置参考](/cn/docs/configuration)。 首次启动时,Agent 会自动向 Server 注册:注册码在首次注册成功时即被消费,Agent 获取每台服务器专属的 Token 并写回配置文件。后续启动改用持久化的 Token,无需再提供注册码。如果某个 Agent 丢失了 Token 需要重新接入,请在设置页重新生成一个新的注册码。 diff --git a/apps/docs/content/docs/cn/server.mdx b/apps/docs/content/docs/cn/server.mdx index 4293534b..9418cdea 100644 --- a/apps/docs/content/docs/cn/server.mdx +++ b/apps/docs/content/docs/cn/server.mdx @@ -60,10 +60,6 @@ max_connections = 10 session_ttl = 86400 max_servers = 0 -[admin] -username = "admin" -password = "" - [rate_limit] login_max = 5 register_max = 3 @@ -106,13 +102,6 @@ file = "" | `session_ttl` | int | `86400` | Session 过期时间,单位秒(默认 24 小时) | | `max_servers` | int | `0` | 通过注册码接入允许创建的新服务器软上限(0 表示不限制) | -#### `[admin]` 初始管理员 - -| 配置项 | 类型 | 默认值 | 说明 | -|--------|------|--------|------| -| `username` | string | `"admin"` | 初始管理员用户名 | -| `password` | string | `""` | 初始管理员密码,留空则自动生成随机密码 | - #### `[rate_limit]` 速率限制 | 配置项 | 类型 | 默认值 | 说明 | diff --git a/apps/docs/content/docs/en/agent.mdx b/apps/docs/content/docs/en/agent.mdx index eb730dda..850a1cf7 100644 --- a/apps/docs/content/docs/en/agent.mdx +++ b/apps/docs/content/docs/en/agent.mdx @@ -118,6 +118,28 @@ enrollment_code = "" When the agent can read a stable machine identifier, it also sends a fingerprint during registration. Repeated registration from the same machine reuses the existing server row instead of creating duplicate placeholders. If a code is lost, expired, or already used, the server responds with HTTP 401 and the agent logs `Registration failed: HTTP 401 ... enrollment code ... expired or already used`; mint a fresh code in Settings to retry. +### Correcting a Wrong Enrollment Code + +If the agent was installed with a mistyped enrollment code (or wrong `server_url`) and has **not yet registered** (no `token` saved), fix it without reinstalling. Enrollment codes are single-use, so a typo'd or already-consumed code cannot be reused — generate a fresh one in **Settings** first if needed, then: + +```bash +serverbee config set enrollment_code -y +# if the server URL was also wrong: +serverbee config set server_url http://your-server-ip:9527 -y +``` + +The `-y` flag restarts the agent so it retries registration immediately. Without `-y` the value is written but the service is not restarted; run `serverbee restart agent` afterwards. + +If you are unsure of the agent's state, simply re-run the agent installer — it rewrites `agent.toml`: + +```bash +serverbee install agent --method \ + --server-url http://your-server-ip:9527 \ + --enrollment-code -y +``` + +Once the agent has **already registered** (a `token` is present in `agent.toml`), the enrollment code is no longer used and does not need correcting; point the agent at a different server only by re-registering with a fresh code from that server. + ### Manual Token If you prefer not to use enrollment codes, you can manually create a server entry in the dashboard and provide the token directly: diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx index b74f3210..454d33f5 100644 --- a/apps/docs/content/docs/en/configuration.mdx +++ b/apps/docs/content/docs/en/configuration.mdx @@ -115,7 +115,7 @@ There is no admin username/password environment variable. On first start (when n | `SERVERBEE_DATABASE__PATH` | `serverbee.db` | SQLite database file path (relative to data_dir) | | `SERVERBEE_DATABASE__MAX_CONNECTIONS` | `10` | Maximum database connection pool size | | `SERVERBEE_AUTH__SESSION_TTL` | `86400` | Session token TTL in seconds (default 24h) | -| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Set Secure flag on session cookies. Set `false` for HTTP-only dev | +| `SERVERBEE_AUTH__SECURE_COOKIE` | `true` | Set the Secure flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP, such as direct IP quick-start installs | | `SERVERBEE_RATE_LIMIT__LOGIN_MAX` | `5` | Max login attempts per IP within 15-minute window | | `SERVERBEE_RATE_LIMIT__REGISTER_MAX` | `3` | Max agent registrations per IP within 15-minute window | | `SERVERBEE_UPGRADE__RELEASE_BASE_URL` | `https://github.com/ZingerLittleBee/ServerBee/releases` | Base URL for agent upgrade release assets | @@ -182,14 +182,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc |-----|------|---------|-------------| | `session_ttl` | i64 | `86400` | Session cookie lifetime in seconds (24 hours) | | `max_servers` | u32 | `0` | Maximum servers allowed via enrollment (0 = no limit). Best-effort soft cap | -| `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Disable only for HTTP-only development | - -### `[admin]` -- Initial Admin Account - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `username` | string | `"admin"` | Admin username (used only on first startup when no users exist) | -| `password` | string | `""` | Admin password. If empty, a random password is generated and logged | +| `secure_cookie` | bool | `true` | Set the `Secure` flag on session cookies. Use `false` only when the browser accesses ServerBee over plain HTTP | ### `[retention]` -- Data Retention @@ -357,11 +350,11 @@ The log level can also be set via the `RUST_LOG` environment variable, which tak ## Example: Minimal Server Configuration ```toml -[admin] -password = "my-secure-password" +[server] +data_dir = "./data" ``` -Everything else uses sensible defaults. This is sufficient to start a working server that listens on port 9527 with a SQLite database in `./data/`. +Everything else uses sensible defaults. On first startup, ServerBee creates the `admin` user with a random password and prints it once in the server logs. ## Example: Production Server Configuration @@ -370,10 +363,6 @@ Everything else uses sensible defaults. This is sufficient to start a working se listen = "127.0.0.1:9527" data_dir = "/var/lib/serverbee" -[admin] -username = "admin" -password = "a-very-strong-password" - [auth] secure_cookie = true diff --git a/apps/docs/content/docs/en/deployment.mdx b/apps/docs/content/docs/en/deployment.mdx index f2935c77..c714978b 100644 --- a/apps/docs/content/docs/en/deployment.mdx +++ b/apps/docs/content/docs/en/deployment.mdx @@ -232,6 +232,38 @@ See the [Agent Setup](/en/docs/agent) guide for a complete systemd service unit Running ServerBee behind a reverse proxy is strongly recommended for production. It provides TLS termination, HTTP/2, and additional security headers. +### Access URL and Cookie Settings + +Choose one public access URL and keep the browser URL, agent `server_url`, and cookie setting aligned: + +| Scenario | Public URL | `auth.secure_cookie` | Docker Environment Variable | Agent URL | +|----------|------------|----------------------|-----------------------------|-----------| +| Direct IP over HTTP | `http://203.0.113.10:9527` | `false` | `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://203.0.113.10:9527` | +| Domain through HTTPS reverse proxy | `https://monitor.example.com` | `true` | `SERVERBEE_AUTH__SECURE_COOKIE=true` or omit the variable | `https://monitor.example.com` | + +For domain access, create a DNS `A` or `AAAA` record pointing to the server, terminate HTTPS in Nginx, Caddy, or Traefik, and proxy traffic to ServerBee on `127.0.0.1:9527`. + +If you installed with the quick-start script, it writes `auth.secure_cookie = false` for direct HTTP access. Before switching that same installation to HTTPS, update `/etc/serverbee/server.toml`: + +```toml +[auth] +secure_cookie = true +``` + +Then restart the server: + +```bash +sudo systemctl restart serverbee-server +``` + +If ServerBee was installed with the install script, you can also let the CLI configure Caddy HTTPS automatically: + +```bash +sudo serverbee domain setup --domain monitor.example.com --email admin@example.com -y +``` + +This command verifies that the domain resolves to the current server, installs and configures Caddy, changes ServerBee to listen only on `127.0.0.1:9527`, and sets `auth.secure_cookie = true`. If DNS is not ready yet, it stops and prints the `A`/`AAAA` record you need to add. + ### Nginx ```nginx @@ -355,7 +387,7 @@ server_url = "https://monitor.example.com" The agent automatically handles WebSocket connections using the provided URL. -When using HTTPS, ensure `auth.secure_cookie = true` in your server configuration (this is the default). If you set it to `false`, session cookies will not be sent over HTTPS connections, breaking browser authentication. +When using HTTPS, keep `auth.secure_cookie = true` in your server configuration. Leaving it `false` may still allow login, but it removes the browser's Secure cookie protection and is not appropriate for production. ## Backup and Restore diff --git a/apps/docs/content/docs/en/quick-start.mdx b/apps/docs/content/docs/en/quick-start.mdx index 618b27ac..95d60369 100644 --- a/apps/docs/content/docs/en/quick-start.mdx +++ b/apps/docs/content/docs/en/quick-start.mdx @@ -27,6 +27,8 @@ services: - "9527:9527" volumes: - serverbee-data:/data + environment: + - SERVERBEE_AUTH__SECURE_COOKIE=false volumes: serverbee-data: @@ -44,16 +46,52 @@ On first start the server auto-creates an admin account with a randomly generate The dashboard is now available at `http://your-server-ip:9527`. + +The compose file above disables the `Secure` cookie flag because this quick start uses plain HTTP. When you move behind HTTPS, set `SERVERBEE_AUTH__SECURE_COOKIE=true`. + + +## IP Address vs Domain Access + +ServerBee can be accessed directly by IP address or through a domain name. The required cookie and URL settings depend on whether the browser uses plain HTTP or HTTPS. + +| Access Mode | Browser URL | Server Cookie Setting | Agent `server_url` | Extra Setup | +|-------------|-------------|-----------------------|--------------------|-------------| +| Direct IP, plain HTTP | `http://your-server-ip:9527` | `auth.secure_cookie = false` or `SERVERBEE_AUTH__SECURE_COOKIE=false` | `http://your-server-ip:9527` | Open port `9527` in your firewall | +| Domain with HTTPS | `https://monitor.example.com` | `auth.secure_cookie = true` or `SERVERBEE_AUTH__SECURE_COOKIE=true` | `https://monitor.example.com` | Point DNS to the server IP and put Nginx, Caddy, or Traefik in front of ServerBee | + + +If you started with the quick-start HTTP settings and later move to a domain with HTTPS, change `secure_cookie` back to `true`, restart ServerBee, and update existing agents to use the new `https://` server URL. + + +If you already have a domain and its DNS `A` record points to this server, the install script can configure Caddy HTTPS automatically: + +```bash +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server \ + --domain monitor.example.com \ + --email admin@example.com \ + -y +``` + +The script first verifies that the domain resolves to the current server. If it does not, installation stops and prints the DNS record you need to add. `--email` is used for Let's Encrypt certificate notices and can be omitted. + ## Option 2: Install Script Download and run the install script for a quick binary installation: ```bash -curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server +curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- server -y ``` This will download the appropriate binary for your platform, create a systemd service, and start ServerBee automatically. +On first start the server auto-creates an admin account with a randomly generated password and prints it once to the service logs. Retrieve it with: + +```bash +sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' +``` + +The install script writes `auth.secure_cookie = false` so browser login works on the plain HTTP quick-start URL. When you move behind HTTPS, set it back to `true` in `/etc/serverbee/server.toml` and restart the server. + After installation, manage your ServerBee instance with the `serverbee` CLI: ```bash @@ -65,16 +103,23 @@ sudo serverbee config set # Update config sudo serverbee uninstall server --purge # Uninstall + remove data ``` +Verify the server is running: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-server -n 80 --no-pager +``` + ## First Login 1. Open your browser and navigate to `http://your-server-ip:9527` -2. Log in with the default credentials: +2. Log in with the first-run credentials: - **Username:** `admin` - - **Password:** `admin` (or the password you configured) -3. **Change the default password immediately** via Settings > Security + - **Password:** the randomly generated password from the startup logs +3. Complete the required password change on first login -If you are using the default password, the server will display a prominent warning in the logs at startup. Always change the default password before exposing the server to the internet. +The generated password is shown only once in the logs. Copy it before logs rotate, then complete the forced password change before exposing the server to the internet. ## Adding Your First Agent @@ -108,6 +153,13 @@ On its first start, the agent will: You should see the new server appear in your dashboard within a few seconds. +Verify the agent is connected and reporting: + +```bash +sudo serverbee status +sudo journalctl -u serverbee-agent -n 80 --no-pager +``` + When the agent can read a stable machine identifier, repeated auto-registration from the same host reuses the existing server entry instead of creating duplicate placeholders. diff --git a/apps/docs/content/docs/en/server.mdx b/apps/docs/content/docs/en/server.mdx index cc70382c..528a28b6 100644 --- a/apps/docs/content/docs/en/server.mdx +++ b/apps/docs/content/docs/en/server.mdx @@ -62,10 +62,6 @@ session_ttl = 86400 # Session lifetime in seconds (default: 24 hours) max_servers = 0 # Soft limit for newly enrolled servers secure_cookie = true # Set Secure flag on session cookies (disable for HTTP-only dev) -[admin] -username = "admin" # Initial admin username -password = "" # Initial admin password (auto-generated if empty) - [retention] records_days = 7 # Raw metric records retention (days) records_hourly_days = 90 # Hourly aggregated records retention (days) diff --git a/apps/web/src/components/data-table/data-table.tsx b/apps/web/src/components/data-table/data-table.tsx index cc906db8..36e8da4c 100644 --- a/apps/web/src/components/data-table/data-table.tsx +++ b/apps/web/src/components/data-table/data-table.tsx @@ -14,8 +14,8 @@ export function DataTable({ table, actionBar, children, className, ...pro return (
{children} -
- +
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/apps/web/src/components/server/add-server-dialog.tsx b/apps/web/src/components/server/add-server-dialog.tsx new file mode 100644 index 00000000..112407da --- /dev/null +++ b/apps/web/src/components/server/add-server-dialog.tsx @@ -0,0 +1,316 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Copy, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' +import { api } from '@/lib/api-client' +import type { CreateEnrollmentResponse, EnrollmentSummary } from '@/lib/api-schema' +import { cn } from '@/lib/utils' + +const TTL_OPTIONS = [ + { secs: 600, key: 'validity_10m' }, + { secs: 3600, key: 'validity_1h' }, + { secs: 86_400, key: 'validity_1d' } +] as const + +type EnrollmentStatus = 'active' | 'consumed' | 'expired' + +function enrollmentStatus(item: EnrollmentSummary): EnrollmentStatus { + if (item.consumed_at) { + return 'consumed' + } + if (new Date(item.expires_at).getTime() < Date.now()) { + return 'expired' + } + return 'active' +} + +function statusVariant(status: EnrollmentStatus): 'default' | 'secondary' | 'destructive' { + if (status === 'active') { + return 'default' + } + if (status === 'consumed') { + return 'secondary' + } + return 'destructive' +} + +export function AddServerDialog({ open, onClose }: { onClose: () => void; open: boolean }) { + const { t } = useTranslation(['servers', 'common']) + const queryClient = useQueryClient() + + const [label, setLabel] = useState('') + const [ttl, setTtl] = useState(600) + const [issued, setIssued] = useState(null) + + const { data: enrollments, isLoading } = useQuery({ + queryKey: ['agent', 'enrollments'], + queryFn: () => api.get('/api/agent/enrollments') + }) + + const generateMutation = useMutation({ + mutationFn: () => + api.post('/api/agent/enrollments', { + label: label.trim() || null, + ttl_secs: ttl + }), + onSuccess: (data) => { + setIssued(data) + queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) + }, + onError: () => toast.error(t('add_server.generate_failed')) + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/api/agent/enrollments/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) + toast.success(t('add_server.deleted')) + }, + onError: () => toast.error(t('add_server.delete_failed')) + }) + + const origin = window.location.origin + const installCommand = issued + ? `curl -fsSL ${origin}/install.sh | sudo bash -s -- --server-url '${origin}' --enrollment-code '${issued.code}'` + : '' + + const copy = async (value: string) => { + try { + await navigator.clipboard.writeText(value) + toast.success(t('add_server.copied')) + } catch { + // Clipboard access denied + } + } + + const reset = () => { + setIssued(null) + setLabel('') + setTtl(600) + } + + const handleClose = () => { + reset() + onClose() + } + + return ( + { + if (!isOpen) { + handleClose() + } + }} + open={open} + > + + + {t('add_server.title')} + + + +

{t('add_server.description')}

+ + {issued ? ( +
+

{t('add_server.code_once_warning')}

+ +
+

{t('add_server.code_label')}

+
+ + {issued.code} + + +
+
+ +
+

{t('add_server.install_command')}

+
+ + {installCommand} + + +
+
+ +
+

{t('add_server.steps_title')}

+
    +
  1. {t('add_server.step1')}
  2. +
  3. {t('add_server.step2')}
  4. +
  5. {t('add_server.step3')}
  6. +
+
+
+ ) : ( +
+
+ + setLabel(e.target.value)} + placeholder={t('add_server.name_placeholder')} + type="text" + value={label} + /> +

{t('add_server.name_hint')}

+
+ +
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: label describes the segmented button group below */} + +
+ {TTL_OPTIONS.map((opt) => ( + + ))} +
+
+
+ )} + +
+

{t('add_server.existing_title')}

+ {(() => { + if (isLoading) { + return + } + if (!enrollments || enrollments.length === 0) { + return

{t('add_server.empty')}

+ } + return ( + +
    + {enrollments.map((item) => { + const status = enrollmentStatus(item) + return ( +
  • +
    +
    + {item.code_prefix}… + {item.label ? ( + {item.label} + ) : null} +
    +

    + {t('add_server.expires_at', { + date: new Date(item.expires_at).toLocaleString() + })} +

    +
    + {t(`add_server.status_${status}`)} + + + + + } + /> + + + {t('add_server.delete_confirm_title')} + + {t('add_server.delete_confirm_description')} + + + + {t('common:cancel')} + deleteMutation.mutate(item.id)} variant="destructive"> + {t('add_server.delete')} + + + + +
  • + ) + })} +
+
+ ) + })()} +
+
+ + + {issued ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/apps/web/src/components/server/cost-footnote.tsx b/apps/web/src/components/server/cost-footnote.tsx index f175f8a1..2f2dc929 100644 --- a/apps/web/src/components/server/cost-footnote.tsx +++ b/apps/web/src/components/server/cost-footnote.tsx @@ -1,13 +1,13 @@ import { useTranslation } from 'react-i18next' import type { ServerCostOverview } from '@/lib/api-schema' -import { formatCostRate, getCostGradeClassName } from '@/lib/cost' -import { cn } from '@/lib/utils' +import { formatCostRate } from '@/lib/cost' interface CostFootnoteProps { entry?: ServerCostOverview + inline?: boolean } -export function CostFootnote({ entry }: CostFootnoteProps) { +export function CostFootnote({ entry, inline = false }: CostFootnoteProps) { const { t } = useTranslation('servers') if (!entry) { @@ -16,7 +16,7 @@ export function CostFootnote({ entry }: CostFootnoteProps) { return ( - + {!inline && } {entry.configured ? ( ) : ( @@ -38,11 +38,11 @@ function ConfiguredFootnote({ entry }: { entry: ServerCostOverview }) { {formatCostRate(entry.cost_per_hour, entry.currency, 'h', { maximumFractionDigits: 4 })} - {entry.value_score && ( + {entry.cost_per_month_equivalent != null && ( <> - - {t(`cost_grade_${entry.value_score.grade}`)} + + {formatCostRate(entry.cost_per_month_equivalent, entry.currency, 'mo', { maximumFractionDigits: 2 })} )} diff --git a/apps/web/src/components/server/network-square-grid.test.tsx b/apps/web/src/components/server/network-square-grid.test.tsx index 9f2f8822..0d8115fb 100644 --- a/apps/web/src/components/server/network-square-grid.test.tsx +++ b/apps/web/src/components/server/network-square-grid.test.tsx @@ -71,6 +71,48 @@ describe('NetworkSquareGrid', () => { expect(squares.length).toBe(10) }) + it('colors synthetic backend-history points that carry a real value', () => { + // @ts-expect-error inject mock + globalThis.ResizeObserver = TestResizeObserver + + const historyPoint: ServerCardMetricPoint = { + synthetic: true, + targets: [], + timestamp: 'synthetic-0', + value: 42 + } + + const { container } = render( + + + + ) + + const square = container.querySelector('[data-testid="square"]') + expect(square?.style.backgroundColor).not.toBe('var(--color-muted)') + }) + + it('renders the unknown color for points without a value', () => { + // @ts-expect-error inject mock + globalThis.ResizeObserver = TestResizeObserver + + const emptyPoint: ServerCardMetricPoint = { + synthetic: true, + targets: [], + timestamp: 'padding-0', + value: null + } + + const { container } = render( + + + + ) + + const square = container.querySelector('[data-testid="square"]') + expect(square?.style.backgroundColor).toBe('var(--color-muted)') + }) + it('renders at least one square even at zero width', () => { // @ts-expect-error inject mock globalThis.ResizeObserver = TestResizeObserver diff --git a/apps/web/src/components/server/network-square-grid.tsx b/apps/web/src/components/server/network-square-grid.tsx index dffbee69..110d89d5 100644 --- a/apps/web/src/components/server/network-square-grid.tsx +++ b/apps/web/src/components/server/network-square-grid.tsx @@ -8,7 +8,7 @@ import { LATENCY_UNKNOWN_BAR_COLOR } from '@/lib/network-latency-constants' import { latencyColorClass } from '@/lib/network-types' -import type { ServerCardMetricPoint } from './server-card-network-data' +import { AGGREGATE_TARGET_ID, type ServerCardMetricPoint } from './server-card-network-data' const SQUARE_SIZE = 6 const SQUARE_GAP = 2 @@ -27,7 +27,7 @@ function averageLossRatio(point: ServerCardMetricPoint): number | null { } function getSquareColor(point: ServerCardMetricPoint, kind: 'latency' | 'loss'): string { - if (point.synthetic) { + if (point.value == null) { return LATENCY_UNKNOWN_BAR_COLOR } if (kind === 'latency') { @@ -87,7 +87,9 @@ function PointTooltip({ point, t }: { point: ServerCardMetricPoint; t: (key: str const failed = isLatencyFailure(target.lossRatio) return (
- {target.targetName} + + {target.targetId === AGGREGATE_TARGET_ID ? t('card_network_avg') : target.targetName} +
{formatLatency(target.latency)} {formatPacketLoss(target.lossRatio)} @@ -138,10 +140,7 @@ export function NetworkSquareGrid({ points, kind }: NetworkSquareGridProps) { /> } /> - + diff --git a/apps/web/src/components/server/server-card-network-data.test.ts b/apps/web/src/components/server/server-card-network-data.test.ts index fbab419c..8fe4221a 100644 --- a/apps/web/src/components/server/server-card-network-data.test.ts +++ b/apps/web/src/components/server/server-card-network-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { NetworkServerSummary } from '@/lib/network-types' -import { buildServerCardNetworkState } from './server-card-network-data' +import { AGGREGATE_TARGET_ID, buildServerCardNetworkState } from './server-card-network-data' function makeSummary(overrides: Partial = {}): NetworkServerSummary { return { @@ -126,9 +126,15 @@ describe('buildServerCardNetworkState', () => { expect(state.currentAvgLossRatio).toBe(0.03) expect(state.latencyPoints.at(-1)?.value).toBe(40) expect(state.lossPoints.at(-1)?.value).toBe(3) + // Each historical point carries its own bucket aggregate, not a constant + // current-snapshot, so tooltips differ per point. expect(state.latencyPoints.at(-1)?.targets).toEqual([ - { latency: 40, lossRatio: 0.03, targetId: 'target-1', targetName: 'Shanghai' } + { latency: 40, lossRatio: 0.03, targetId: AGGREGATE_TARGET_ID, targetName: AGGREGATE_TARGET_ID } ]) + expect(state.latencyPoints.at(-3)?.targets).toEqual([ + { latency: 10, lossRatio: 0.01, targetId: AGGREGATE_TARGET_ID, targetName: AGGREGATE_TARGET_ID } + ]) + expect(state.latencyPoints.at(-2)?.targets).toEqual([]) }) it('keeps backend seed data and appends realtime points when live samples arrive', () => { diff --git a/apps/web/src/components/server/server-card-network-data.ts b/apps/web/src/components/server/server-card-network-data.ts index ee521259..44e6b86a 100644 --- a/apps/web/src/components/server/server-card-network-data.ts +++ b/apps/web/src/components/server/server-card-network-data.ts @@ -2,6 +2,8 @@ import type { NetworkProbeResultData, NetworkServerSummary, NetworkTargetSummary const MAX_TREND_POINTS = 30 +export const AGGREGATE_TARGET_ID = '__aggregate__' + export interface ServerCardTooltipTarget { latency: number | null lossRatio: number @@ -170,17 +172,30 @@ function buildFallbackState( for (let index = startIndex; index < pointCount; index += 1) { const timestamp = buildSyntheticTimestamp(index) + const latencyValue = latencySparkline[index] ?? null + const lossValue = lossSparkline[index] ?? null + const bucketTargets: ServerCardTooltipTarget[] = + latencyValue == null && lossValue == null + ? [] + : [ + { + latency: latencyValue, + lossRatio: lossValue ?? 0, + targetId: AGGREGATE_TARGET_ID, + targetName: AGGREGATE_TARGET_ID + } + ] latencyPoints.push({ synthetic: true, - targets: fallbackTargets, + targets: bucketTargets, timestamp, - value: latencySparkline[index] ?? null + value: latencyValue }) lossPoints.push({ synthetic: true, - targets: fallbackTargets, + targets: bucketTargets, timestamp, - value: lossSparkline[index] == null ? null : (lossSparkline[index] as number) * 100 + value: lossValue == null ? null : lossValue * 100 }) } diff --git a/apps/web/src/components/server/server-card.test.tsx b/apps/web/src/components/server/server-card.test.tsx index 4d87740d..1501889b 100644 --- a/apps/web/src/components/server/server-card.test.tsx +++ b/apps/web/src/components/server/server-card.test.tsx @@ -7,6 +7,7 @@ import { CostFootnote } from './cost-footnote' import { ServerCard } from './server-card' const REGEX_COST_PER_HOUR = /0\.01\/h/ +const REGEX_COST_PER_MONTH = /7\.30\/mo/ vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -122,9 +123,7 @@ describe('ServerCard', () => { renderCard(makeServer()) expect(screen.getByText('col_uptime')).toBeDefined() expect(screen.getByText('card_swap')).toBeDefined() - expect(screen.getByText('card_processes')).toBeDefined() - expect(screen.getByText('card_tcp')).toBeDefined() - expect(screen.getByText('card_udp')).toBeDefined() + expect(screen.getByText('card_proc_conn_label')).toBeDefined() }) it('renders compact cost footnote when cost overview is available', () => { @@ -135,15 +134,10 @@ describe('ServerCard', () => { { configured: true, cost_per_hour: 0.01, + cost_per_month_equivalent: 7.3, currency: 'USD', name: 'test-server', - server_id: 'srv-1', - value_score: { - confidence: 'high', - grade: 'good', - reasons: [], - score: 82 - } + server_id: 'srv-1' } ] } satisfies CostOverviewResponse @@ -152,8 +146,8 @@ describe('ServerCard', () => { renderCard(makeServer()) expect(screen.getByText(REGEX_COST_PER_HOUR)).toBeDefined() - expect(screen.getByText('cost_grade_good')).toBeDefined() - expect(screen.queryByText('82')).toBeNull() + expect(screen.getByText(REGEX_COST_PER_MONTH)).toBeDefined() + expect(screen.queryByText('cost_grade_good')).toBeNull() }) it('renders compact unconfigured cost footnote labels', () => { diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index 0b65316f..ab415d8e 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -237,32 +237,32 @@ const ServerCardInner = ({ server }: ServerCardProps) => { - R + + + {t('card_disk_read')} } value={renderSpeedValue(server.disk_read_bytes_per_sec)} /> - W + + + {t('card_disk_write')} } value={renderSpeedValue(server.disk_write_bytes_per_sec)} /> -
{hasNetworkData && ( @@ -289,34 +289,51 @@ const ServerCardInner = ({ server }: ServerCardProps) => { )} -
- - {t('col_uptime')}{' '} +
+
+ {t('col_uptime')} {formatUptime(server.uptime)} - - - - {t('card_swap')} {`${swapPct.toFixed(0)}%`} - - - - {t('card_processes')} {server.process_count} - - - - {t('card_tcp')} {server.tcp_conn} - - - - {t('card_udp')} {server.udp_conn} - - {trafficDaysRemaining != null && ( - <> +
+
+ {t('card_swap')} + {`${swapPct.toFixed(0)}%`} +
+
+ {t('card_load_trend')} + + {formatLoad(server.load5)} - {t('card_traffic_days_left', { count: trafficDaysRemaining })} - + {formatLoad(server.load15)} + +
+
+ {t('card_proc_conn_label')} + + {`${server.process_count} / ${server.tcp_conn} / ${server.udp_conn}`} + +
+ {trafficDaysRemaining == null ? ( + + ) : ( +
+ {t('card_traffic_days_left_label')} + + {t('card_traffic_days_value', { count: trafficDaysRemaining })} + +
+ )} + {costEntry?.configured ? ( +
+ {t('card_cost_label')} + +
+ ) : ( + )} -
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index cfdb78bb..98a0f0ee 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -35,14 +35,14 @@ function TooltipContent({ > {children} - + diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index 4a721183..d2b2c7f0 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -1,21 +1,57 @@ { "title": "Servers", + "add_server.button": "Add Server", + "add_server.title": "Add a Server", + "add_server.description": "Set the basics, generate a one-time enrollment code, then run the install command on your VPS.", + "add_server.name_label": "Name (optional)", + "add_server.name_placeholder": "e.g. tokyo-vps-01", + "add_server.name_hint": "A label to recognize this enrollment code in the list below.", + "add_server.validity_label": "Code validity", + "add_server.validity_10m": "10 minutes", + "add_server.validity_1h": "1 hour", + "add_server.validity_1d": "1 day", + "add_server.generate": "Generate code", + "add_server.generating": "Generating…", + "add_server.generate_failed": "Failed to generate enrollment code", + "add_server.code_once_warning": "Copy this now — the code is shown only once and cannot be retrieved again.", + "add_server.code_label": "Enrollment code", + "add_server.install_command": "Install command (run on your VPS as root)", + "add_server.steps_title": "Next steps", + "add_server.step1": "Copy the install command above.", + "add_server.step2": "SSH into your VPS and run it as root.", + "add_server.step3": "The agent registers automatically and appears in the server list.", + "add_server.copy": "Copy", + "add_server.copied": "Copied", + "add_server.done": "Done", + "add_server.another": "Generate another", + "add_server.existing_title": "Existing codes", + "add_server.empty": "No enrollment codes yet", + "add_server.expires_at": "Expires {{date}}", + "add_server.status_active": "Active", + "add_server.status_consumed": "Used", + "add_server.status_expired": "Expired", + "add_server.delete": "Delete", + "add_server.delete_confirm_title": "Delete enrollment code?", + "add_server.delete_confirm_description": "This permanently revokes the code. An agent that has not yet enrolled with it will fail to connect.", + "add_server.deleted": "Enrollment code deleted", + "add_server.delete_failed": "Failed to delete enrollment code", "card_latency": "Latency", "card_load": "Load", "card_net_total": "Total", "card_network_quality": "Network Quality", + "card_network_avg": "Average", "card_packet_loss": "Loss", - "card_processes": "Processes", "card_swap": "Swap", - "card_tcp": "TCP", - "card_udp": "UDP", "card_net_in_speed": "↓ In", "card_net_out_speed": "↑ Out", - "card_disk_read": "↺ Read", - "card_disk_write": "↻ Write", + "card_disk_read": "Read", + "card_disk_write": "Write", "card_load_trend": "Load 5m·15m", "card_traffic_quota": "Traffic", - "card_traffic_days_left": "{{count}}d left", + "card_traffic_days_left_label": "Days left", + "card_traffic_days_value": "{{count}}d", + "card_proc_conn_label": "Proc / TCP / UDP", + "card_cost_label": "Cost", "servers_online": "{{online}} of {{total}} servers online", "search_placeholder": "Search servers\u2026", "delete_selected": "Delete {{count}}", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index 92733640..27d2027f 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -1,21 +1,57 @@ { "title": "服务器", + "add_server.button": "添加服务器", + "add_server.title": "添加服务器", + "add_server.description": "填写基础信息,生成一次性注册码,然后在 VPS 上执行安装命令。", + "add_server.name_label": "名称(可选)", + "add_server.name_placeholder": "例如 tokyo-vps-01", + "add_server.name_hint": "用于在下方列表中识别此注册码的备注。", + "add_server.validity_label": "注册码有效期", + "add_server.validity_10m": "10 分钟", + "add_server.validity_1h": "1 小时", + "add_server.validity_1d": "1 天", + "add_server.generate": "生成注册码", + "add_server.generating": "生成中…", + "add_server.generate_failed": "生成注册码失败", + "add_server.code_once_warning": "请立即复制 —— 注册码只显示一次,无法再次获取。", + "add_server.code_label": "注册码", + "add_server.install_command": "安装命令(在 VPS 上以 root 执行)", + "add_server.steps_title": "后续步骤", + "add_server.step1": "复制上面的安装命令。", + "add_server.step2": "SSH 登录 VPS 并以 root 执行。", + "add_server.step3": "Agent 会自动注册并出现在服务器列表中。", + "add_server.copy": "复制", + "add_server.copied": "已复制", + "add_server.done": "完成", + "add_server.another": "再生成一个", + "add_server.existing_title": "已有注册码", + "add_server.empty": "暂无注册码", + "add_server.expires_at": "过期时间 {{date}}", + "add_server.status_active": "有效", + "add_server.status_consumed": "已使用", + "add_server.status_expired": "已过期", + "add_server.delete": "删除", + "add_server.delete_confirm_title": "删除注册码?", + "add_server.delete_confirm_description": "将永久吊销此码。尚未用它注册的 Agent 将无法连接。", + "add_server.deleted": "注册码已删除", + "add_server.delete_failed": "删除注册码失败", "card_latency": "延迟", "card_load": "负载", "card_net_total": "总流量", "card_network_quality": "网络质量", + "card_network_avg": "平均", "card_packet_loss": "丢包", - "card_processes": "进程", "card_swap": "Swap", - "card_tcp": "TCP", - "card_udp": "UDP", "card_net_in_speed": "↓ 入站", "card_net_out_speed": "↑ 出站", - "card_disk_read": "↺ 读", - "card_disk_write": "↻ 写", + "card_disk_read": "读", + "card_disk_write": "写", "card_load_trend": "负载 5m·15m", "card_traffic_quota": "流量", - "card_traffic_days_left": "剩 {{count}} 天", + "card_traffic_days_left_label": "剩余天数", + "card_traffic_days_value": "{{count}} 天", + "card_proc_conn_label": "进程 / TCP / UDP", + "card_cost_label": "费用", "servers_online": "{{total}} 台服务器中 {{online}} 台在线", "search_placeholder": "搜索服务器\u2026", "delete_selected": "删除 {{count}} 项", diff --git a/apps/web/src/routes/_authed/servers/index.tsx b/apps/web/src/routes/_authed/servers/index.tsx index fe1e3740..15128631 100644 --- a/apps/web/src/routes/_authed/servers/index.tsx +++ b/apps/web/src/routes/_authed/servers/index.tsx @@ -1,13 +1,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import type { ColumnDef } from '@tanstack/react-table' -import { CircleDot, ExternalLink, LayoutGrid, Search, Table2, Tag, Trash2 } from 'lucide-react' +import { CircleDot, ExternalLink, LayoutGrid, Plus, Search, Table2, Tag, Trash2 } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { DataTable } from '@/components/data-table/data-table' import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' import { DataTableToolbar } from '@/components/data-table/data-table-toolbar' +import { AddServerDialog } from '@/components/server/add-server-dialog' import { CostCell } from '@/components/server/cost-cell' import { ServerCard } from '@/components/server/server-card' import { ServerEditDialog } from '@/components/server/server-edit-dialog' @@ -30,6 +31,7 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useServer } from '@/hooks/use-api' +import { useAuth } from '@/hooks/use-auth' import { useCostOverview } from '@/hooks/use-cost' import { useDataTable } from '@/hooks/use-data-table' import type { ServerMetrics } from '@/hooks/use-servers-ws' @@ -65,6 +67,9 @@ const arrayIncludesFilter = (row: { getValue: (id: string) => unknown }, id: str function ServersListPage() { const { t } = useTranslation(['servers', 'common']) const queryClient = useQueryClient() + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const [addOpen, setAddOpen] = useState(false) const navigate = Route.useNavigate() const { q: search, view: viewParam } = Route.useSearch() @@ -341,6 +346,12 @@ function ServersListPage() { {t('servers_online', { online: servers.filter((s) => s.online).length, total: servers.length })}

+ {isAdmin && ( + + )}
@@ -438,7 +449,7 @@ function ServersListPage() { )} {servers.length > 0 && viewMode === 'grid' && ( -
+
{filtered.map((server) => (
@@ -448,6 +459,7 @@ function ServersListPage() { )} {editingId !== null && setEditingId(null)} serverId={editingId} />} + {isAdmin && setAddOpen(false)} open={addOpen} />}
) } diff --git a/apps/web/src/routes/_authed/settings/index.tsx b/apps/web/src/routes/_authed/settings/index.tsx index bdb69bbe..907111dd 100644 --- a/apps/web/src/routes/_authed/settings/index.tsx +++ b/apps/web/src/routes/_authed/settings/index.tsx @@ -1,220 +1,19 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { Copy, Plus, Trash2 } from 'lucide-react' -import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' import { GeoIpCard } from '@/components/settings/geoip-card' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from '@/components/ui/alert-dialog' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Skeleton } from '@/components/ui/skeleton' -import { api } from '@/lib/api-client' -import type { CreateEnrollmentResponse, EnrollmentSummary } from '@/lib/api-schema' export const Route = createFileRoute('/_authed/settings/')({ component: SettingsPage }) -type EnrollmentStatus = 'active' | 'consumed' | 'expired' - -function enrollmentStatus(item: EnrollmentSummary): EnrollmentStatus { - if (item.consumed_at) { - return 'consumed' - } - if (new Date(item.expires_at).getTime() < Date.now()) { - return 'expired' - } - return 'active' -} - -function statusVariant(status: EnrollmentStatus): 'default' | 'secondary' | 'destructive' { - if (status === 'active') { - return 'default' - } - if (status === 'consumed') { - return 'secondary' - } - return 'destructive' -} - function SettingsPage() { const { t } = useTranslation('settings') - const queryClient = useQueryClient() - const [issuedCode, setIssuedCode] = useState(null) - - const { data: enrollments, isLoading } = useQuery({ - queryKey: ['agent', 'enrollments'], - queryFn: () => api.get('/api/agent/enrollments') - }) - - const generateMutation = useMutation({ - mutationFn: () => api.post('/api/agent/enrollments', {}), - onSuccess: (data) => { - setIssuedCode(data.code) - queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) - }, - onError: () => toast.error(t('enrollment.generate_failed')) - }) - - const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/api/agent/enrollments/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['agent', 'enrollments'] }) - toast.success(t('enrollment.deleted')) - }, - onError: () => toast.error(t('enrollment.delete_failed')) - }) - - const installCommand = issuedCode - ? `curl -fsSL ${window.location.origin}/install.sh | sudo bash -s -- --server-url '${window.location.origin}' --enrollment-code '${issuedCode}'` - : '' - - const copy = async (value: string) => { - try { - await navigator.clipboard.writeText(value) - toast.success(t('copied')) - } catch { - // Clipboard access denied - } - } return (

{t('title')}

-
-

{t('enrollment.title')}

-

{t('enrollment.description')}

- - - - {issuedCode ? ( -
-

{t('enrollment.code_once_warning')}

- -
-

{t('enrollment.code_label')}

-
- - {issuedCode} - - -
-
- -
-

{t('enrollment.install_command')}

-
- - {installCommand} - - -
-
-
- ) : null} - -
-

{t('enrollment.list_title')}

- {(() => { - if (isLoading) { - return - } - if (!enrollments || enrollments.length === 0) { - return

{t('enrollment.empty')}

- } - return ( - -
    - {enrollments.map((item) => { - const status = enrollmentStatus(item) - return ( -
  • -
    -
    - {item.code_prefix}… - {item.label ? ( - {item.label} - ) : null} -
    -

    - {t('enrollment.expires_at', { - date: new Date(item.expires_at).toLocaleString() - })} -

    -
    - {t(`enrollment.status_${status}`)} - - - - - } - /> - - - {t('enrollment.delete_confirm_title')} - - {t('enrollment.delete_confirm_description')} - - - - {t('common:cancel')} - deleteMutation.mutate(item.id)} variant="destructive"> - {t('enrollment.delete')} - - - - -
  • - ) - })} -
-
- ) - })()} -
-
-
diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 8191666d..e647977a 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -429,6 +429,7 @@ impl AppConfig { pub fn load() -> anyhow::Result { let config: AppConfig = Figment::new() .merge(Toml::file("/etc/serverbee/server.toml")) + .merge(Toml::file("/opt/serverbee/etc/server.toml")) .merge(Toml::file("server.toml")) .merge(Env::prefixed("SERVERBEE_").split("__")) .extract()?; diff --git a/deploy/install.sh b/deploy/install.sh index 83629494..bc9fb6a6 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,14 +1,49 @@ #!/usr/bin/env bash set -euo pipefail +# Requires bash 4.0+ (associative arrays for i18n string tables). +if [ -z "${BASH_VERSINFO:-}" ] || [ "${BASH_VERSINFO[0]}" -lt 4 ]; then + echo "ServerBee installer requires bash 4.0+ (found ${BASH_VERSION:-unknown})." >&2 + echo "On macOS: 'brew install bash' then run it with that bash." >&2 + exit 1 +fi + +# Absolute path to this script when run as a regular file; empty when the +# script was piped via stdin (curl | bash). Used so the installed +# management CLI matches the installer that created the layout, instead +# of an out-of-sync released copy. +SELF_SCRIPT="" +case "${BASH_SOURCE[0]:-}" in + "" | bash | sh | -bash | -sh) ;; + *) + if [ -r "${BASH_SOURCE[0]}" ]; then + SELF_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/$(basename "${BASH_SOURCE[0]}")" + fi + ;; +esac + # ─── Constants ──────────────────────────────────────────────────────────────── REPO="ZingerLittleBee/ServerBee" -INSTALL_DIR="/usr/local/bin" -CONFIG_DIR="/etc/serverbee" -DATA_DIR="/var/lib/serverbee" -DOCKER_DIR="/opt/serverbee" +# Everything ServerBee installs lives under a single base directory for +# unified management. The PATH-visible management CLI is the only exception. +BASE_DIR="/opt/serverbee" +INSTALL_DIR="${BASE_DIR}/bin" +CONFIG_DIR="${BASE_DIR}/etc" +DATA_DIR="${BASE_DIR}/data" +DOCKER_DIR="${BASE_DIR}" +DEFAULT_DOCKER_DIR="${BASE_DIR}" +SNAP_DOCKER_DIR="/var/snap/docker/common/serverbee" META_FILE="${CONFIG_DIR}/.install-meta" +LANG_CACHE_FILE="${CONFIG_DIR}/.install-lang" +CLI_PATH="/usr/local/bin/serverbee" +# Legacy FHS-split layout (pre-/opt). Kept only for one-time auto-migration +# of installs created by older versions of this script. +LEGACY_BIN_DIR="/usr/local/bin" +LEGACY_CONFIG_DIR="/etc/serverbee" +LEGACY_DATA_DIR="/var/lib/serverbee" DOCS_URL="https://server-bee-docs.vercel.app" +CADDY_CONFIG_DIR="/etc/caddy" +CADDYFILE="${CADDY_CONFIG_DIR}/Caddyfile" # ─── Globals ────────────────────────────────────────────────────────────────── COMMAND="" @@ -16,11 +51,15 @@ COMPONENT="" METHOD="" SERVER_URL="" ENROLLMENT_CODE="" -PASSWORD="" +DOMAIN="" +EMAIL="" +LANG_CODE="${SERVERBEE_LANG:-}" YES=false PURGE=false +SKIP_DNS_CHECK=false CONFIG_KEY="" CONFIG_VALUE="" +MISSING_DEPS=() # ─── Colors ─────────────────────────────────────────────────────────────────── RED='\033[0;31m' @@ -34,6 +73,347 @@ info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +should_prompt() { + [ "$YES" != true ] && [ -t 0 ] +} + +normalize_lang() { + case "${LANG_CODE:-}" in + zh|zh_*|zh-*|cn|CN) LANG_CODE="zh" ;; + en|en_*|en-*) LANG_CODE="en" ;; + "") ;; + *) error "Unsupported language: ${LANG_CODE} (use 'en' or 'zh')" ;; + esac +} + +lang_cache_read() { + [ -f "$LANG_CACHE_FILE" ] || return 1 + local cached + cached=$(head -n1 "$LANG_CACHE_FILE" 2>/dev/null | tr -d '[:space:]') + case "$cached" in + en|zh) printf '%s' "$cached"; return 0 ;; + *) return 1 ;; + esac +} + +lang_cache_write() { + [ -n "${LANG_CODE:-}" ] || return 0 + mkdir -p "$CONFIG_DIR" 2>/dev/null || return 0 + printf '%s\n' "$LANG_CODE" > "$LANG_CACHE_FILE" 2>/dev/null || return 0 + chmod 600 "$LANG_CACHE_FILE" 2>/dev/null || true +} + +detect_lang() { + if [ -n "${LANG_CODE:-}" ]; then + normalize_lang + return + fi + + local cached + if cached=$(lang_cache_read); then + LANG_CODE="$cached" + return + fi + + case "${LC_ALL:-${LANG:-en}}" in + zh*|ZH*) LANG_CODE="zh" ;; + *) LANG_CODE="en" ;; + esac +} + +select_language() { + [ -z "${LANG_CODE:-}" ] || { normalize_lang; return; } + + local cached + if cached=$(lang_cache_read); then + LANG_CODE="$cached" + return + fi + + if ! should_prompt; then + detect_lang + return + fi + + local choice + echo "" + echo -e "${BOLD}Select language / 选择语言${NC}" + echo "" + echo " [1] English" + echo " [2] 简体中文" + echo "" + read -rp "Select language [1/2]: " choice + case "$choice" in + 1|en|EN|English|english) LANG_CODE="en" ;; + 2|zh|ZH|cn|CN|中文) LANG_CODE="zh" ;; + *) error "Invalid language choice: ${choice}" ;; + esac + lang_cache_write +} + +# i18n string tables. Add new user-facing strings here in both languages. +# Parametrized strings use printf format specifiers (%s) — render via trp(). +declare -A I18N_EN=( + [manager_title]="ServerBee Manager" + [install_menu]=" [1] Install 安装" + [uninstall_menu]=" [2] Uninstall 卸载" + [upgrade_menu]=" [3] Upgrade 升级" + [status_menu]=" [4] Status 查看状态" + [service_menu]=" [5] Service 服务控制 (start/stop/restart)" + [config_menu]=" [6] Config 配置管理" + [env_menu]=" [7] Env 环境变量" + [domain_menu]=" [8] Domain 域名 HTTPS" + [exit_menu]=" [0] Exit 退出" + [select_menu]="Select [0-8]: " + [install_title]="Install" + [agent_option]=" [1] Agent — System metrics collector" + [server_option]=" [2] Server — Dashboard & API" + [select_component]="Select component [1/2]: " + [server_docker_recommended]=" [1] Docker (recommended for Server)" + [agent_binary_recommended]=" [1] Binary (recommended for Agent)" + [binary_option]=" [2] Binary" + [docker_option]=" [2] Docker" + [select_method]="Select installation method [1/2]: " + [configure_domain]="Configure HTTPS domain with Caddy now? [y/N]: " + [domain_prompt]="Domain (e.g., monitor.example.com): " + [email_prompt]="Email for certificate notices (optional): " + [server_url_prompt]="Server URL [%s]: " + [enrollment_prompt]="Enrollment code: " + [install_plan_title]="Installation plan" + [domain_plan_title]="Domain setup plan" + [will_add_download]="Will add or download:" + [start_install]="Start installation now? [y/N]: " + [start_domain]="Start domain setup now? [y/N]: " + [preflight]="Preflight checks:" + [svc_title]="Service control" + [svc_start]=" [1] Start" + [svc_stop]=" [2] Stop" + [svc_restart]=" [3] Restart" + [svc_select]="Select [1-3]: " + [uninstall_title]="Uninstall" + [opt_agent]=" [1] Agent" + [opt_server]=" [2] Server" + [uninstall_confirm]="Uninstall serverbee-%s (%s)%s? [y/N]: " + [uninstall_purge_note]=" (including config and data)" + [uninstall_preserved]=" Config and data preserved. To remove them, run:" + [deps_install_confirm]=" Install them now? [y/N]: " + [docker_continue_confirm]=" Continue with Docker? [y/N]: " + [docker_agent_note]=" ServerBee Agent is portable software:" + [docker_agent_note1]=" - Single binary, no residual files" + [docker_agent_note2]=" - Docker requires --privileged for full metrics" + [docker_agent_note3]=" - Web terminal accesses container, not host" + [upgrade_confirm]="Proceed with upgrade? [y/N]: " + [restart_apply_q]=" Restart service to apply changes?" + [restart_apply_confirm]=" [y/N]: " + [plan_component]="Component:" + [plan_method]="Method:" + [plan_access]="Access:" + [plan_access_ip_val]="IP / direct port (:9527)" + [plan_access_domain_val]="domain" + [plan_server_url]="Server URL:" + [plan_cfg_file]=" - Config file:" + [plan_data_dir]=" - Data directory:" + [plan_compose_file]=" - Compose file:" + [plan_docker_volume]=" - Docker volume: serverbee-data" + [plan_systemd]=" - systemd service:" + [plan_pkgs]=" - System packages:" + [plan_pkgs_suffix]="(required script tools)" + [plan_gh_meta]=" - GitHub API: latest ServerBee release metadata" + [plan_binary_adopt_pre]=" - Binary: existing" + [plan_binary_adopt_suf]="will be adopted (no binary download)" + [plan_binary_dl]=" - Binary:" + [plan_cli_script]=" - CLI script:" + [plan_docker_prereq]=" - Prerequisite: Docker and Docker Compose V2 must already be installed" + [plan_docker_image]=" - Docker image:" + [domain_plan_header]="HTTPS domain setup:" + [dp_dns_pre]=" - DNS validation:" + [dp_dns_suf]="must resolve to this server" + [dp_repo]=" - Caddy repository: Cloudsmith apt repo on Debian/Ubuntu, or COPR on Fedora/CentOS" + [dp_key]=" - Caddy apt key:" + [dp_src]=" - Caddy apt source:" + [dp_pkgs]=" - System packages: Caddy and its repository dependencies when missing" + [dp_caddyfile]=" - Caddyfile:" + [dp_bind]=" - Server bind address: 127.0.0.1:9527" + [dp_cookie]=" - secure_cookie: true" + [dp_url]=" - Public URL:" + [domain_label]="Domain:" + [email_label]="Email: " + [result_server_ok]="ServerBee Server installed successfully!" + [result_agent_ok]="ServerBee Agent installed successfully!" + [lbl_dashboard]=" Dashboard:" + [lbl_username]=" Username:" + [lbl_password]=" Password:" + [pw_docker]="(auto-generated, check: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_systemd]="(auto-generated, check: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_proc]="(auto-generated, check process output for 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_must_change]=" (one-time password — you must change it on first login)" + [lbl_docs]=" Docs:" + [lbl_server_url]=" Server URL:" + [lbl_logs]=" Logs:" + [lbl_start]=" Start:" + [lbl_config]=" Config:" + [status_none]="No ServerBee components found. Run 'serverbee install' to get started." + [status_title]="ServerBee Status" + [st_version]=" Version:" + [st_binary]=" Binary:" + [st_config]=" Config:" + [st_service]=" Service:" + [st_active]="active (running)" + [st_since]="since" + [st_recent_logs]=" Recent logs:" + [st_no_logs]=" (no logs)" + [st_server]=" Server:" + [st_dashboard]=" Dashboard:" + [st_container]=" Container:" + [st_stopped]="stopped" + [st_image]=" Image:" + [st_port]=" Port:" + [st_unknown]="unknown" +) +declare -A I18N_ZH=( + [manager_title]="ServerBee 管理器" + [install_menu]=" [1] 安装 Install" + [uninstall_menu]=" [2] 卸载 Uninstall" + [upgrade_menu]=" [3] 升级 Upgrade" + [status_menu]=" [4] 状态 Status" + [service_menu]=" [5] 服务控制 Service (start/stop/restart)" + [config_menu]=" [6] 配置管理 Config" + [env_menu]=" [7] 环境变量 Env" + [domain_menu]=" [8] 域名 HTTPS Domain" + [exit_menu]=" [0] 退出 Exit" + [select_menu]="选择 [0-8]: " + [install_title]="安装" + [agent_option]=" [1] Agent — 系统指标采集器" + [server_option]=" [2] Server — 控制台和 API" + [select_component]="选择组件 [1/2]: " + [server_docker_recommended]=" [1] Docker (Server 推荐)" + [agent_binary_recommended]=" [1] Binary (Agent 推荐)" + [binary_option]=" [2] Binary" + [docker_option]=" [2] Docker" + [select_method]="选择安装方式 [1/2]: " + [configure_domain]="现在配置 HTTPS 域名(Caddy)吗?[y/N]: " + [domain_prompt]="域名(例如 monitor.example.com): " + [email_prompt]="证书通知邮箱(可选): " + [server_url_prompt]="Server URL [%s]: " + [enrollment_prompt]="Enrollment code(注册码): " + [install_plan_title]="安装计划" + [domain_plan_title]="域名配置计划" + [will_add_download]="将添加或下载:" + [start_install]="现在开始安装?[y/N]: " + [start_domain]="现在开始域名配置?[y/N]: " + [preflight]="安装前检查:" + [svc_title]="服务控制" + [svc_start]=" [1] 启动" + [svc_stop]=" [2] 停止" + [svc_restart]=" [3] 重启" + [svc_select]="选择 [1-3]: " + [uninstall_title]="卸载" + [opt_agent]=" [1] Agent" + [opt_server]=" [2] Server" + [uninstall_confirm]="卸载 serverbee-%s(%s)%s ? [y/N]: " + [uninstall_purge_note]="(含配置与数据)" + [uninstall_preserved]=" 配置与数据已保留,如需移除请执行:" + [deps_install_confirm]=" 现在安装它们?[y/N]: " + [docker_continue_confirm]=" 仍然继续使用 Docker?[y/N]: " + [docker_agent_note]=" ServerBee Agent 是便携软件:" + [docker_agent_note1]=" - 单一二进制,无残留文件" + [docker_agent_note2]=" - Docker 需 --privileged 才能采集完整指标" + [docker_agent_note3]=" - Web 终端访问的是容器而非宿主机" + [upgrade_confirm]="确认升级?[y/N]: " + [restart_apply_q]=" 重启服务以应用更改?" + [restart_apply_confirm]=" [y/N]: " + [plan_component]="组件:" + [plan_method]="方式:" + [plan_access]="访问:" + [plan_access_ip_val]="IP / 直连端口 (:9527)" + [plan_access_domain_val]="域名" + [plan_server_url]="Server URL:" + [plan_cfg_file]=" - 配置文件:" + [plan_data_dir]=" - 数据目录:" + [plan_compose_file]=" - Compose 文件:" + [plan_docker_volume]=" - Docker 卷: serverbee-data" + [plan_systemd]=" - systemd 服务:" + [plan_pkgs]=" - 系统软件包:" + [plan_pkgs_suffix]="(脚本所需工具)" + [plan_gh_meta]=" - GitHub API: 最新 ServerBee 发布元数据" + [plan_binary_adopt_pre]=" - 二进制: 已存在" + [plan_binary_adopt_suf]="将被沿用(不下载二进制)" + [plan_binary_dl]=" - 二进制:" + [plan_cli_script]=" - CLI 脚本:" + [plan_docker_prereq]=" - 前置条件: 需已安装 Docker 与 Docker Compose V2" + [plan_docker_image]=" - Docker 镜像:" + [domain_plan_header]="HTTPS 域名配置:" + [dp_dns_pre]=" - DNS 校验:" + [dp_dns_suf]="必须解析到本机" + [dp_repo]=" - Caddy 仓库: Debian/Ubuntu 用 Cloudsmith apt 源,Fedora/CentOS 用 COPR" + [dp_key]=" - Caddy apt key:" + [dp_src]=" - Caddy apt source:" + [dp_pkgs]=" - 系统软件包: 缺失时安装 Caddy 及其仓库依赖" + [dp_caddyfile]=" - Caddyfile:" + [dp_bind]=" - 服务监听地址: 127.0.0.1:9527" + [dp_cookie]=" - secure_cookie: true" + [dp_url]=" - 公网地址:" + [domain_label]="域名:" + [email_label]="邮箱: " + [result_server_ok]="ServerBee Server 安装成功!" + [result_agent_ok]="ServerBee Agent 安装成功!" + [lbl_dashboard]=" 控制台:" + [lbl_username]=" 用户名:" + [lbl_password]=" 密码:" + [pw_docker]="(自动生成,查看: docker compose -f %s logs serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_systemd]="(自动生成,查看: sudo journalctl -u serverbee-server | grep -A8 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_proc]="(自动生成,在进程输出中查找 'FIRST-RUN ADMIN CREDENTIALS')" + [pw_must_change]=" (一次性密码 —— 首次登录后必须修改)" + [lbl_docs]=" 文档:" + [lbl_server_url]=" Server URL:" + [lbl_logs]=" 日志:" + [lbl_start]=" 启动:" + [lbl_config]=" 配置:" + [status_none]="未找到任何 ServerBee 组件。运行 'serverbee install' 开始安装。" + [status_title]="ServerBee 状态" + [st_version]=" 版本:" + [st_binary]=" 二进制:" + [st_config]=" 配置:" + [st_service]=" 服务:" + [st_active]="运行中" + [st_since]="自" + [st_recent_logs]=" 最近日志:" + [st_no_logs]=" (无日志)" + [st_server]=" Server:" + [st_dashboard]=" 控制台:" + [st_container]=" 容器:" + [st_stopped]="已停止" + [st_image]=" 镜像:" + [st_port]=" 端口:" + [st_unknown]="未知" +) + +tr_text() { + local key="$1" val + if [ "${LANG_CODE:-en}" = "zh" ]; then + val="${I18N_ZH[$key]-}" + else + val="${I18N_EN[$key]-}" + fi + # Fall back to English, then to a visible marker so gaps are obvious. + [ -n "$val" ] || val="${I18N_EN[$key]-??${key}??}" + echo "$val" +} + +# Docs site language segment (apps/docs is bilingual: cn / en). +docs_lang() { + [ "${LANG_CODE:-en}" = "zh" ] && echo "cn" || echo "en" +} + +# Translate + printf for parametrized strings (no trailing newline added). +trp() { + local key="$1"; shift + local fmt + fmt="$(tr_text "$key")" + # shellcheck disable=SC2059 + printf "$fmt" "$@" +} + # ─── Dependency check ───────────────────────────────────────────────────────── install_deps() { # Auto-install missing packages using the available package manager @@ -69,7 +449,7 @@ check_deps() { install_deps "${missing[@]}" else warn "Missing required tools: ${missing[*]}" - read -rp " Install them now? [y/N]: " confirm + read -rp "$(tr_text deps_install_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) install_deps "${missing[@]}" ;; *) error "Cannot continue without: ${missing[*]}" ;; @@ -77,6 +457,59 @@ check_deps() { fi } +collect_missing_deps() { + MISSING_DEPS=() + local cmd + for cmd in curl grep sed awk mktemp; do + if ! command -v "$cmd" &>/dev/null; then + MISSING_DEPS+=("$cmd") + fi + done +} + +docker_is_snap() { + command -v docker &>/dev/null || return 1 + local docker_path + docker_path=$(command -v docker) + case "$(readlink -f "$docker_path" 2>/dev/null || echo "$docker_path")" in + /snap/*|/usr/bin/snap) return 0 ;; + *) return 1 ;; + esac +} + +configure_docker_dir() { + if docker_is_snap; then + DOCKER_DIR="$SNAP_DOCKER_DIR" + else + DOCKER_DIR="$DEFAULT_DOCKER_DIR" + fi +} + +# Config directory for docker-mode components. The snap-confined Docker +# daemon cannot bind-mount paths under /opt or /etc (its rootfs is +# read-only), so config that must be visible inside a container has to +# live under the snap-accessible tree. For non-snap Docker this is the +# normal CONFIG_DIR, so binary mode and non-snap Docker are unchanged. +docker_conf_dir() { + if docker_is_snap; then + echo "${SNAP_DOCKER_DIR}/etc" + else + echo "${CONFIG_DIR}" + fi +} + +# Resolve a component's config file based on how it was installed. +# Docker-managed components may live under the snap-accessible tree. +conf_file_for() { + local comp="$1" method + method=$(meta_read "$comp" "method" 2>/dev/null || echo "") + if [ "$method" = "docker" ]; then + echo "$(docker_conf_dir)/${comp}.toml" + else + echo "${CONFIG_DIR}/${comp}.toml" + fi +} + # ─── Root check ─────────────────────────────────────────────────────────────── require_root() { if [ "$(id -u)" -ne 0 ]; then @@ -84,8 +517,90 @@ require_root() { fi } +# ─── Legacy layout migration ────────────────────────────────────────────────── +# Older versions of this script used an FHS-split layout: +# /usr/local/bin/serverbee-* /etc/serverbee /var/lib/serverbee +# Newer installs are consolidated under ${BASE_DIR}. This migrates an existing +# legacy install in place so that upgrade/uninstall/config keep working. +migrate_legacy_layout() { + # Already on the new layout — nothing to do. + [ -f "$META_FILE" ] && return 0 + + local legacy_meta="${LEGACY_CONFIG_DIR}/.install-meta" + local has_legacy=false + [ -f "$legacy_meta" ] && has_legacy=true + [ -f "${LEGACY_BIN_DIR}/serverbee-server" ] && has_legacy=true + [ -f "${LEGACY_BIN_DIR}/serverbee-agent" ] && has_legacy=true + [ "$has_legacy" = true ] || return 0 + + info "Detected legacy install layout — migrating to ${BASE_DIR}" + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" + + local comp + for comp in server agent; do + if [ -f "${LEGACY_BIN_DIR}/serverbee-${comp}" ]; then + mv -f "${LEGACY_BIN_DIR}/serverbee-${comp}" "${INSTALL_DIR}/serverbee-${comp}" + fi + done + + if [ -d "$LEGACY_CONFIG_DIR" ]; then + local f + for f in "$LEGACY_CONFIG_DIR"/* "$LEGACY_CONFIG_DIR"/.install-meta; do + [ -e "$f" ] || continue + mv -f "$f" "$CONFIG_DIR"/ 2>/dev/null || true + done + rmdir "$LEGACY_CONFIG_DIR" 2>/dev/null || true + fi + + if [ -d "$LEGACY_DATA_DIR" ] && [ "$LEGACY_DATA_DIR" != "$DATA_DIR" ]; then + local d + for d in "$LEGACY_DATA_DIR"/* "$LEGACY_DATA_DIR"/.[!.]*; do + [ -e "$d" ] || continue + mv -f "$d" "$DATA_DIR"/ 2>/dev/null || true + done + rmdir "$LEGACY_DATA_DIR" 2>/dev/null || true + fi + + # Point server.toml at the new data directory if it referenced the old one. + if [ -f "${CONFIG_DIR}/server.toml" ]; then + sed -i "s#${LEGACY_DATA_DIR}#${DATA_DIR}#g" "${CONFIG_DIR}/server.toml" 2>/dev/null || true + fi + + # Rewrite systemd units to the new paths and restart anything running. + if has_systemd; then + local unit svc + for comp in server agent; do + svc="serverbee-${comp}" + unit="/etc/systemd/system/${svc}.service" + [ -f "$unit" ] || continue + sed -i \ + -e "s#${LEGACY_BIN_DIR}/serverbee-${comp}#${INSTALL_DIR}/serverbee-${comp}#g" \ + -e "s#WorkingDirectory=${LEGACY_DATA_DIR}#WorkingDirectory=${CONFIG_DIR}#g" \ + -e "s#WorkingDirectory=${LEGACY_CONFIG_DIR}#WorkingDirectory=${CONFIG_DIR}#g" \ + -e "s#SERVERBEE_SERVER__DATA_DIR=${LEGACY_DATA_DIR}#SERVERBEE_SERVER__DATA_DIR=${DATA_DIR}#g" \ + "$unit" 2>/dev/null || true + done + systemctl daemon-reload 2>/dev/null || true + for comp in server agent; do + svc="serverbee-${comp}" + if systemctl is-active --quiet "$svc" 2>/dev/null; then + systemctl restart "$svc" 2>/dev/null || true + fi + done + fi + + info "Migration complete — ServerBee now lives under ${BASE_DIR}" +} + # ─── Known subcommands ─────────────────────────────────────────────────────── -KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env" +KNOWN_COMMANDS="install uninstall upgrade status start stop restart config env domain" + +is_known_command() { + case "$1" in + install|uninstall|upgrade|status|start|stop|restart|config|env|domain) return 0 ;; + *) return 1 ;; + esac +} # ─── Argument parsing ───────────────────────────────────────────────────────── parse_args() { @@ -94,7 +609,11 @@ parse_args() { --method) METHOD="$2"; shift 2 ;; --server-url) SERVER_URL="$2"; shift 2 ;; --enrollment-code) ENROLLMENT_CODE="$2"; shift 2 ;; - --password) PASSWORD="$2"; shift 2 ;; + --password) error "--password is no longer supported. ServerBee always generates a one-time first-run admin password; check the server logs after installation." ;; + --domain) DOMAIN="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --lang) LANG_CODE="$2"; normalize_lang; shift 2 ;; + --skip-dns-check) SKIP_DNS_CHECK=true; shift ;; --purge) PURGE=true; shift ;; --yes|-y) YES=true; shift ;; -*) error "Unknown option: $1" ;; @@ -133,22 +652,207 @@ detect_arch() { esac } +RESOLVED_VERSION="" get_latest_version() { + if [ -n "$RESOLVED_VERSION" ]; then + echo "$RESOLVED_VERSION" + return + fi local tag tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ | grep '"tag_name"' \ | head -1 \ | sed 's/.*"tag_name": *"//;s/".*//') [ -z "$tag" ] && error "Failed to get latest version from GitHub" + RESOLVED_VERSION="$tag" echo "$tag" } +docker_image_tag() { + local version="$1" + echo "${version#v}" +} + get_local_ip() { ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ || hostname -I 2>/dev/null | awk '{print $1}' \ || echo "localhost" } +get_public_ipv4() { + if [ -n "${SERVERBEE_TEST_PUBLIC_IPV4:-}" ]; then + echo "$SERVERBEE_TEST_PUBLIC_IPV4" + return + fi + curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true +} + +get_public_ipv6() { + if [ -n "${SERVERBEE_TEST_PUBLIC_IPV6:-}" ]; then + echo "$SERVERBEE_TEST_PUBLIC_IPV6" + return + fi + curl -6 -fsS --max-time 5 https://api6.ipify.org 2>/dev/null || true +} + +resolve_domain_a() { + local domain="$1" + if [ -n "${SERVERBEE_TEST_DNS_A:-}" ]; then + echo "$SERVERBEE_TEST_DNS_A" | tr ',' '\n' | sed '/^$/d' + return + fi + if command -v getent &>/dev/null; then + getent ahostsv4 "$domain" 2>/dev/null | awk '{print $1}' | sort -u + elif command -v dig &>/dev/null; then + dig +short A "$domain" 2>/dev/null | sed '/^$/d' + elif command -v host &>/dev/null; then + host -t A "$domain" 2>/dev/null | awk '/has address/ {print $4}' + fi +} + +resolve_domain_aaaa() { + local domain="$1" + if [ -n "${SERVERBEE_TEST_DNS_AAAA:-}" ]; then + echo "$SERVERBEE_TEST_DNS_AAAA" | tr ',' '\n' | sed '/^$/d' + return + fi + if command -v getent &>/dev/null; then + getent ahostsv6 "$domain" 2>/dev/null | awk '{print $1}' | sort -u + elif command -v dig &>/dev/null; then + dig +short AAAA "$domain" 2>/dev/null | sed '/^$/d' + elif command -v host &>/dev/null; then + host -t AAAA "$domain" 2>/dev/null | awk '/has IPv6 address/ {print $5}' + fi +} + +validate_domain_name() { + local domain="$1" + [[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] \ + || error "Invalid domain: ${domain}\n Use a hostname like monitor.example.com, without http:// or a path." +} + +line_contains_value() { + local haystack="$1" needle="$2" + [ -n "$needle" ] && echo "$haystack" | grep -Fxq "$needle" +} + +domain_points_to_server() { + local dns_a="$1" dns_aaaa="$2" public_ipv4="$3" public_ipv6="$4" + line_contains_value "$dns_a" "$public_ipv4" || line_contains_value "$dns_aaaa" "$public_ipv6" +} + +warn_mismatched_aaaa_if_present() { + local domain="$1" dns_aaaa="$2" public_ipv6="$3" + + [ -n "$dns_aaaa" ] || return 0 + if [ -n "$public_ipv6" ] && line_contains_value "$dns_aaaa" "$public_ipv6"; then + return 0 + fi + + if [ "${LANG_CODE:-en}" = "zh" ]; then + warn "${domain} 的 AAAA 记录没有指向当前服务器。" + if [ -n "$public_ipv6" ]; then + echo " 当前服务器 IPv6: ${public_ipv6}" + echo " DNS AAAA: ${dns_aaaa}" + echo " 请修正 AAAA 记录;如果只使用 IPv4,请删除 AAAA 记录。" + else + echo " 当前服务器未检测到公网 IPv6,但 DNS 存在 AAAA: ${dns_aaaa}" + echo " 除非你确认 IPv6 可以访问这台服务器,否则请删除 AAAA 记录。" + fi + echo " Caddy/Let's Encrypt 可能会尝试 IPv6,导致证书申请失败。" + else + warn "AAAA record for ${domain} does not point to this server." + if [ -n "$public_ipv6" ]; then + echo " Current server IPv6: ${public_ipv6}" + echo " DNS AAAA: ${dns_aaaa}" + echo " Fix the AAAA record or remove it if you only want IPv4." + else + echo " This server has no detected public IPv6, but DNS has AAAA: ${dns_aaaa}" + echo " Remove the AAAA record unless you have verified IPv6 reaches this server." + fi + echo " Caddy/Let's Encrypt may try IPv6 and certificate issuance may fail." + fi +} + +print_dns_mismatch_help() { + local domain="$1" public_ipv4="$2" public_ipv6="$3" dns_a="$4" dns_aaaa="$5" + + if [ "${LANG_CODE:-en}" = "zh" ]; then + echo "" + echo "域名 ${domain} 还没有解析到当前服务器。" + echo "" + echo "当前服务器 IP:" + echo " IPv4: ${public_ipv4:-未知}" + echo " IPv6: ${public_ipv6:-未知}" + echo "" + echo "当前 DNS 记录:" + echo " A: ${dns_a:-无}" + echo " AAAA: ${dns_aaaa:-无}" + echo "" + echo "请添加或更新 DNS:" + [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" + [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" + echo "" + echo "继续之前 DNS 必须匹配。" + echo "如果不匹配,Caddy/Let's Encrypt 证书申请会失败。" + echo "更新 DNS 后按 Enter 重新校验,按 Ctrl+C 停止。" + echo "" + else + echo "" + echo "Domain ${domain} does not resolve to this server yet." + echo "" + echo "Current server IP:" + echo " IPv4: ${public_ipv4:-unknown}" + echo " IPv6: ${public_ipv6:-unknown}" + echo "" + echo "Current DNS records:" + echo " A: ${dns_a:-none}" + echo " AAAA: ${dns_aaaa:-none}" + echo "" + echo "Please add/update DNS:" + [ -n "$public_ipv4" ] && echo " A ${domain} -> ${public_ipv4}" + [ -n "$public_ipv6" ] && echo " AAAA ${domain} -> ${public_ipv6}" + echo "" + echo "DNS must match before continuing." + echo "If this does not match, Caddy/Let's Encrypt certificate issuance will fail." + echo "Update DNS, then press Enter to check again. Press Ctrl+C to stop." + echo "" + fi +} + +check_domain_points_here() { + local domain="$1" + if [ "$SKIP_DNS_CHECK" = true ]; then + warn "Skipping DNS check for ${domain}." + return + fi + + local public_ipv4 public_ipv6 dns_a dns_aaaa + public_ipv4=$(get_public_ipv4) + public_ipv6=$(get_public_ipv6) + + while true; do + dns_a=$(resolve_domain_a "$domain" || true) + dns_aaaa=$(resolve_domain_aaaa "$domain" || true) + + if domain_points_to_server "$dns_a" "$dns_aaaa" "$public_ipv4" "$public_ipv6"; then + info "DNS check passed: ${domain} resolves to this server." + warn_mismatched_aaaa_if_present "$domain" "$dns_aaaa" "$public_ipv6" + return + fi + + print_dns_mismatch_help "$domain" "$public_ipv4" "$public_ipv6" "$dns_a" "$dns_aaaa" + if ! should_prompt; then + error "DNS validation failed for ${domain}. Fix DNS and re-run, or pass --skip-dns-check if you have configured TLS another way." + fi + if [ "${LANG_CODE:-en}" = "zh" ]; then + read -rp "按 Enter 重新校验 DNS..." _ + else + read -rp "Press Enter to re-check DNS..." _ + fi + done +} + # ─── Install metadata (.install-meta JSON) ─────────────────────────────────── # Uses basic grep/sed for JSON manipulation to avoid jq dependency. # The JSON is simple (flat per-component objects) and always written by us. @@ -322,6 +1026,7 @@ has_systemd() { check_docker() { command -v docker &>/dev/null || error "Docker is not installed. Install it first: https://docs.docker.com/get-docker/" docker compose version &>/dev/null || error "Docker Compose V2 is not available. Install it first: https://docs.docker.com/compose/install/" + configure_docker_dir } check_unmanaged_container() { @@ -338,7 +1043,7 @@ check_unmanaged_container() { # ─── CLI self-install ──────────────────────────────────────────────────────── install_cli() { - local target="/usr/local/bin/serverbee" + local target="$CLI_PATH" local version="${1:-main}" # Entire body runs in a subshell so any failure (cp, curl, chmod, mv) @@ -351,10 +1056,15 @@ install_cli() { tmp=$(mktemp "${target_dir}/.serverbee-cli.XXXXXX") trap 'rm -f "$tmp"' EXIT - # Always download from the release tag to avoid version skew - # between binaries (latest release) and CLI (possibly stale checkout) - local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" - curl -fsSL -o "$tmp" "$url" + # Prefer the running script so the CLI always matches the layout + # it just created. Fall back to the released copy only when piped + # via stdin (curl | bash), where no script file is available. + if [ -n "$SELF_SCRIPT" ] && [ -r "$SELF_SCRIPT" ]; then + cp "$SELF_SCRIPT" "$tmp" + else + local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" + curl -fsSL -o "$tmp" "$url" + fi chmod +x "$tmp" mv "$tmp" "$target" @@ -366,6 +1076,48 @@ install_cli() { fi } +# Refresh the installed management CLI from the release script itself. +# Unlike install_cli (which self-copies the running script to match the +# layout it just created), this pulls the target release's deploy/install.sh +# so `serverbee upgrade` also updates the installer logic, not just the +# monitored component. Validates before atomically replacing; never aborts +# the caller — a stale CLI is non-fatal and the component upgrade already +# succeeded. The running process keeps old code; the new CLI applies on the +# next invocation. +CLI_REFRESHED="" +refresh_cli_from_release() { + local version="${1:-main}" + [ -z "$CLI_REFRESHED" ] || return 0 + + local target="$CLI_PATH" + if ( + local target_dir + target_dir=$(dirname "$target") + local tmp + tmp=$(mktemp "${target_dir}/.serverbee-cli.XXXXXX") + trap 'rm -f "$tmp"' EXIT + + local url="https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" + curl -fsSL -o "$tmp" "$url" || exit 1 + + # Sanity-check the download before trusting it as our own CLI: + # non-empty, syntactically valid bash, and carrying the expected + # repo marker (guards against HTML error pages / truncated bodies). + [ -s "$tmp" ] || exit 1 + bash -n "$tmp" 2>/dev/null || exit 1 + grep -q 'REPO="ZingerLittleBee/ServerBee"' "$tmp" || exit 1 + + chmod +x "$tmp" + mv "$tmp" "$target" + trap - EXIT + ); then + CLI_REFRESHED=1 + info "Management CLI refreshed to ${version} (applies on next 'serverbee' run)" + else + warn "Could not refresh management CLI from ${version} — keeping existing CLI" + fi +} + # ─── Install helpers ───────────────────────────────────────────────────────── install_binary_server() { @@ -374,6 +1126,8 @@ install_binary_server() { arch=$(detect_arch) version=$(get_latest_version) + mkdir -p "$INSTALL_DIR" + # Download (skip if binary already exists — adopt mode) if [ -f "${INSTALL_DIR}/serverbee-server" ]; then warn "Binary already exists at ${INSTALL_DIR}/serverbee-server — skipping download (adopting existing)" @@ -395,14 +1149,10 @@ install_binary_server() { cat > "${CONFIG_DIR}/server.toml" << TOML [server] data_dir = "${DATA_DIR}" -TOML - if [ -n "$PASSWORD" ]; then - cat >> "${CONFIG_DIR}/server.toml" << TOML -[admin] -password = "${PASSWORD}" +[auth] +secure_cookie = false TOML - fi info "Created ${CONFIG_DIR}/server.toml" else warn "${CONFIG_DIR}/server.toml already exists, not overwriting" @@ -410,16 +1160,16 @@ TOML # systemd service if has_systemd; then - cat > /etc/systemd/system/serverbee-server.service << 'UNIT' + cat > /etc/systemd/system/serverbee-server.service << UNIT [Unit] Description=ServerBee Dashboard After=network.target [Service] Type=simple -ExecStart=/usr/local/bin/serverbee-server -WorkingDirectory=/var/lib/serverbee -Environment=SERVERBEE_SERVER__DATA_DIR=/var/lib/serverbee +ExecStart=${INSTALL_DIR}/serverbee-server +WorkingDirectory=${CONFIG_DIR} +Environment=SERVERBEE_SERVER__DATA_DIR=${DATA_DIR} Restart=always RestartSec=5 LimitNOFILE=65536 @@ -446,6 +1196,8 @@ install_binary_agent() { arch=$(detect_arch) version=$(get_latest_version) + mkdir -p "$INSTALL_DIR" + # Download (skip if binary already exists — adopt mode) if [ -f "${INSTALL_DIR}/serverbee-agent" ]; then warn "Binary already exists at ${INSTALL_DIR}/serverbee-agent — skipping download (adopting existing)" @@ -479,15 +1231,15 @@ TOML # systemd service if has_systemd; then - cat > /etc/systemd/system/serverbee-agent.service << 'UNIT' + cat > /etc/systemd/system/serverbee-agent.service << UNIT [Unit] Description=ServerBee Agent After=network.target [Service] Type=simple -ExecStart=/usr/local/bin/serverbee-agent -WorkingDirectory=/etc/serverbee +ExecStart=${INSTALL_DIR}/serverbee-agent +WorkingDirectory=${CONFIG_DIR} Restart=always RestartSec=5 AmbientCapabilities=CAP_NET_RAW @@ -512,38 +1264,29 @@ install_docker_server() { check_docker check_unmanaged_container "server" - local version + local version image_tag version=$(get_latest_version) + image_tag=$(docker_image_tag "$version") - mkdir -p "$DOCKER_DIR" "$CONFIG_DIR" + local conf_dir + conf_dir="$(docker_conf_dir)" + mkdir -p "$DOCKER_DIR" "$conf_dir" # Generate server.toml (skip if exists) - if [ ! -f "${CONFIG_DIR}/server.toml" ]; then - cat > "${CONFIG_DIR}/server.toml" << TOML + if [ ! -f "${conf_dir}/server.toml" ]; then + cat > "${conf_dir}/server.toml" << TOML [server] data_dir = "/data" TOML - if [ -n "$PASSWORD" ]; then - cat >> "${CONFIG_DIR}/server.toml" << TOML - -[admin] -password = "${PASSWORD}" -TOML - fi - info "Created ${CONFIG_DIR}/server.toml" + info "Created ${conf_dir}/server.toml" else - warn "${CONFIG_DIR}/server.toml already exists, not overwriting" - fi - - local password_env="" - if [ -n "$PASSWORD" ]; then - password_env=" - SERVERBEE_ADMIN__PASSWORD=${PASSWORD}" + warn "${conf_dir}/server.toml already exists, not overwriting" fi cat > "${DOCKER_DIR}/docker-compose.server.yml" << YAML services: serverbee-server: - image: ghcr.io/zingerlittlebee/serverbee-server:${version} + image: ghcr.io/zingerlittlebee/serverbee-server:${image_tag} container_name: serverbee-server ports: - "9527:9527" @@ -551,8 +1294,8 @@ services: - serverbee-data:/data environment: - SERVERBEE_ADMIN__USERNAME=admin -${password_env:+${password_env} -} restart: unless-stopped + - SERVERBEE_AUTH__SECURE_COOKIE=false + restart: unless-stopped healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:9527/healthz"] interval: 30s @@ -577,14 +1320,17 @@ install_docker_agent() { check_docker check_unmanaged_container "agent" - local version + local version image_tag version=$(get_latest_version) + image_tag=$(docker_image_tag "$version") - mkdir -p "$CONFIG_DIR" + local conf_dir + conf_dir="$(docker_conf_dir)" + mkdir -p "$conf_dir" # Generate agent.toml (skip if exists) - if [ ! -f "${CONFIG_DIR}/agent.toml" ]; then - cat > "${CONFIG_DIR}/agent.toml" << TOML + if [ ! -f "${conf_dir}/agent.toml" ]; then + cat > "${conf_dir}/agent.toml" << TOML server_url = "${SERVER_URL}" enrollment_code = "${ENROLLMENT_CODE}" @@ -592,9 +1338,9 @@ enrollment_code = "${ENROLLMENT_CODE}" interval = 3 enable_temperature = true TOML - info "Created ${CONFIG_DIR}/agent.toml" + info "Created ${conf_dir}/agent.toml" else - warn "${CONFIG_DIR}/agent.toml already exists, not overwriting" + warn "${conf_dir}/agent.toml already exists, not overwriting" fi mkdir -p "$DOCKER_DIR" @@ -602,7 +1348,7 @@ TOML cat > "${DOCKER_DIR}/docker-compose.agent.yml" << YAML services: serverbee-agent: - image: ghcr.io/zingerlittlebee/serverbee-agent:${version} + image: ghcr.io/zingerlittlebee/serverbee-agent:${image_tag} container_name: serverbee-agent privileged: true network_mode: host @@ -611,7 +1357,7 @@ services: - /proc:/host/proc:ro - /sys:/host/sys:ro - /etc/machine-id:/etc/machine-id:ro - - /etc/serverbee:/etc/serverbee + - ${conf_dir}:/etc/serverbee restart: unless-stopped YAML @@ -624,62 +1370,501 @@ YAML print_agent_result } +# Poll the server's startup logs for the one-time first-run admin password. +# Echoes the password if found within the timeout, otherwise nothing (e.g. +# re-install/adopt where the admin already exists, or no captured logs). +fetch_first_run_password() { + local i out pw max + # Docker's first run may pull the image before the container starts and + # logs the banner, so allow a longer budget; the loop exits as soon as + # the password is found, keeping the warm-cache path fast. + if [ "$METHOD" = "docker" ]; then max=45; else max=15; fi + for ((i = 0; i < max; i++)); do + if [ "$METHOD" = "docker" ]; then + out=$(docker compose -f "${DOCKER_DIR}/docker-compose.server.yml" logs --no-color serverbee-server 2>/dev/null) + elif has_systemd; then + out=$(journalctl -u serverbee-server --no-pager 2>/dev/null) + else + return 0 + fi + pw=$(printf '%s\n' "$out" \ + | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -A8 'FIRST-RUN ADMIN CREDENTIALS' \ + | grep -m1 'Password:' \ + | sed -E 's/.*Password:[[:space:]]*//' \ + | awk '{print $1}') + if [ -n "$pw" ]; then + printf '%s' "$pw" + return 0 + fi + sleep 1 + done + return 0 +} + print_server_result() { - local ip + local ip pw ip=$(get_local_ip) + pw="$(fetch_first_run_password)" echo "" - echo -e "${GREEN}ServerBee Server installed successfully!${NC}" + echo -e "${GREEN}$(tr_text result_server_ok)${NC}" echo "" - echo " Dashboard: http://${ip}:9527" - echo " Username: admin" - if [ -n "$PASSWORD" ]; then - echo " Password: ${PASSWORD}" + echo "$(tr_text lbl_dashboard) http://${ip}:9527" + echo "$(tr_text lbl_username) admin" + if [ -n "$pw" ]; then + echo -e "$(tr_text lbl_password) ${BOLD}${pw}${NC}" + echo "$(tr_text pw_must_change)" elif [ "$METHOD" = "docker" ]; then - echo " Password: (auto-generated, check: docker compose -f ${DOCKER_DIR}/docker-compose.server.yml logs | grep 'Generated admin password')" + echo "$(tr_text lbl_password) $(trp pw_docker "${DOCKER_DIR}/docker-compose.server.yml")" elif has_systemd; then - echo " Password: (auto-generated, check: sudo journalctl -u serverbee-server | grep 'Generated admin password')" + echo "$(tr_text lbl_password) $(tr_text pw_systemd)" else - echo " Password: (auto-generated, check process output for 'Generated admin password')" + echo "$(tr_text lbl_password) $(tr_text pw_proc)" fi echo "" - echo " Docs: ${DOCS_URL}/en/docs/configuration" + echo "$(tr_text lbl_docs) ${DOCS_URL}/$(docs_lang)/docs/configuration" echo "" } print_agent_result() { echo "" - echo -e "${GREEN}ServerBee Agent installed successfully!${NC}" + echo -e "${GREEN}$(tr_text result_agent_ok)${NC}" echo "" - echo " Server URL: ${SERVER_URL}" + echo "$(tr_text lbl_server_url) ${SERVER_URL}" if [ "$METHOD" = "docker" ]; then - echo " Logs: docker compose -f ${DOCKER_DIR}/docker-compose.agent.yml logs -f" + echo "$(tr_text lbl_logs) docker compose -f ${DOCKER_DIR}/docker-compose.agent.yml logs -f" elif has_systemd; then - echo " Start: sudo systemctl start serverbee-agent" - echo " Logs: sudo journalctl -u serverbee-agent -f" + echo "$(tr_text lbl_start) sudo systemctl start serverbee-agent" + echo "$(tr_text lbl_logs) sudo journalctl -u serverbee-agent -f" + else + echo "$(tr_text lbl_start) ${INSTALL_DIR}/serverbee-agent &" + fi + echo "" + if [ "$METHOD" = "docker" ]; then + echo "$(tr_text lbl_config) $(docker_conf_dir)/agent.toml" + else + echo "$(tr_text lbl_config) ${CONFIG_DIR}/agent.toml" + fi + echo "$(tr_text lbl_docs) ${DOCS_URL}/$(docs_lang)/docs/configuration" + echo "" +} + +# ─── Domain / HTTPS setup ───────────────────────────────────────────────────── + +install_caddy() { + if command -v caddy &>/dev/null; then + info "Caddy is already installed" + return + fi + + if command -v apt-get &>/dev/null; then + info "Installing Caddy via official apt repository..." + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl gpg >/dev/null 2>&1 \ + || error "Failed to install Caddy apt repository dependencies" + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor --batch --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + > /etc/apt/sources.list.d/caddy-stable.list + chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list + apt-get update -qq >/dev/null 2>&1 + apt-get install -y -qq caddy >/dev/null 2>&1 || error "Failed to install Caddy" + elif command -v dnf &>/dev/null; then + info "Installing Caddy via COPR repository..." + dnf install -y -q dnf-plugins-core >/dev/null 2>&1 || dnf install -y -q dnf5-plugins >/dev/null 2>&1 \ + || error "Failed to install dnf COPR plugin" + dnf copr enable -y @caddy/caddy >/dev/null 2>&1 || error "Failed to enable Caddy COPR repository" + dnf install -y -q caddy >/dev/null 2>&1 || error "Failed to install Caddy" + elif command -v yum &>/dev/null; then + info "Installing Caddy via COPR repository..." + yum install -y -q yum-plugin-copr >/dev/null 2>&1 || error "Failed to install yum COPR plugin" + yum copr enable -y @caddy/caddy >/dev/null 2>&1 || error "Failed to enable Caddy COPR repository" + yum install -y -q caddy >/dev/null 2>&1 || error "Failed to install Caddy" + else + error "Cannot install Caddy automatically on this distribution.\n Install Caddy manually, then configure:\n\n ${DOMAIN} {\n reverse_proxy 127.0.0.1:9527\n }" + fi +} + +check_http_ports_available() { + local listeners="" + if command -v ss &>/dev/null; then + listeners=$(ss -ltnp 2>/dev/null | awk '$4 ~ /:80$/ || $4 ~ /:443$/ {print}' || true) + elif command -v lsof &>/dev/null; then + listeners=$(lsof -nP -iTCP:80 -iTCP:443 -sTCP:LISTEN 2>/dev/null || true) + fi + + if [ -n "$listeners" ] && ! echo "$listeners" | grep -qi caddy; then + echo "$listeners" | sed 's/^/ /' + error "Port 80 or 443 is already used by a non-Caddy service.\n Stop that service or configure your existing reverse proxy manually." + fi +} + +write_caddyfile() { + mkdir -p "$CADDY_CONFIG_DIR" + if [ -f "$CADDYFILE" ]; then + cp "$CADDYFILE" "${CADDYFILE}.serverbee.$(date +%Y%m%d%H%M%S).bak" + if [ -n "$EMAIL" ] && ! grep -q "^[[:space:]]*email[[:space:]]" "$CADDYFILE"; then + local first_nonblank + first_nonblank=$(awk 'NF {print; exit}' "$CADDYFILE") + if [ "$first_nonblank" = "{" ]; then + awk -v email="$EMAIL" ' + !inserted && $0 == "{" { print; print " email "email; inserted=1; next } + { print } + ' "$CADDYFILE" > /tmp/serverbee-caddyfile + else + { + echo "{" + echo " email ${EMAIL}" + echo "}" + echo "" + cat "$CADDYFILE" + } > /tmp/serverbee-caddyfile + fi + mv /tmp/serverbee-caddyfile "$CADDYFILE" + fi + if grep -q "^${DOMAIN}[[:space:]]*{" "$CADDYFILE"; then + awk -v domain="$DOMAIN" ' + $0 ~ "^"domain"[[:space:]]*{" { print domain" {\n reverse_proxy 127.0.0.1:9527\n}"; in_block=1; depth=1; next } + in_block { + depth += gsub(/\{/, "{") + depth -= gsub(/\}/, "}") + if (depth <= 0) in_block=0 + next + } + { print } + ' "$CADDYFILE" > /tmp/serverbee-caddyfile + mv /tmp/serverbee-caddyfile "$CADDYFILE" + else + cat >> "$CADDYFILE" << EOF + +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + fi + else + if [ -n "$EMAIL" ]; then + cat > "$CADDYFILE" << EOF +{ + email ${EMAIL} +} + +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + else + cat > "$CADDYFILE" << EOF +${DOMAIN} { + reverse_proxy 127.0.0.1:9527 +} +EOF + fi + fi + info "Configured ${CADDYFILE} for ${DOMAIN}" +} + +update_server_for_domain_binary() { + [ -f "${CONFIG_DIR}/server.toml" ] || error "Server config not found: ${CONFIG_DIR}/server.toml" + toml_set "${CONFIG_DIR}/server.toml" "server.listen" "127.0.0.1:9527" + toml_set "${CONFIG_DIR}/server.toml" "auth.secure_cookie" "true" + if has_systemd; then + systemctl restart serverbee-server + fi +} + +update_server_for_domain_docker() { + local compose_file="${DOCKER_DIR}/docker-compose.server.yml" + [ -f "$compose_file" ] || error "Compose file not found: $compose_file" + + sed -i.bak 's|- "9527:9527"|- "127.0.0.1:9527:9527"|' "$compose_file" && rm -f "${compose_file}.bak" + if grep -q "SERVERBEE_AUTH__SECURE_COOKIE=" "$compose_file"; then + sed -i.bak 's|SERVERBEE_AUTH__SECURE_COOKIE=.*|SERVERBEE_AUTH__SECURE_COOKIE=true|' "$compose_file" && rm -f "${compose_file}.bak" + else + sed -i.bak '/environment:/a\ - SERVERBEE_AUTH__SECURE_COOKIE=true' "$compose_file" && rm -f "${compose_file}.bak" + fi + docker compose -f "$compose_file" up -d +} + +wait_for_https_endpoint() { + local url="https://${DOMAIN}/healthz" + local attempts=30 + local delay=2 + local attempt + + for ((attempt = 1; attempt <= attempts; attempt++)); do + if curl -fsS --max-time 20 "$url" >/dev/null; then + return 0 + fi + + if [ "$attempt" -lt "$attempts" ]; then + info "HTTPS endpoint is not ready yet (attempt ${attempt}/${attempts}); retrying in ${delay}s..." + sleep "$delay" + fi + done + + return 1 +} + +setup_domain() { + validate_domain_name "$DOMAIN" + check_domain_points_here "$DOMAIN" + check_http_ports_available + install_caddy + write_caddyfile + + detect_installed + meta_has "server" || error "serverbee-server is not installed. Install the server first." + + local method + method=$(meta_read "server" "method") + case "$method" in + binary) update_server_for_domain_binary ;; + docker) update_server_for_domain_docker ;; + *) error "Unsupported server install method for domain setup: ${method}" ;; + esac + + if has_systemd; then + systemctl enable caddy >/dev/null 2>&1 || true + systemctl restart caddy else - echo " Start: ${INSTALL_DIR}/serverbee-agent &" + warn "systemd not found. Start Caddy manually with: caddy run --config ${CADDYFILE}" fi + + info "Verifying HTTPS endpoint..." + wait_for_https_endpoint \ + || error "HTTPS verification failed for https://${DOMAIN}/healthz. Check Caddy logs and DNS propagation." + + echo "" + echo -e "${GREEN}ServerBee HTTPS domain configured successfully!${NC}" echo "" - echo " Config: ${CONFIG_DIR}/agent.toml" - echo " Docs: ${DOCS_URL}/en/docs/configuration" + echo " Dashboard: https://${DOMAIN}" + echo " Agent URL: https://${DOMAIN}" + echo "" + echo "Install an agent with:" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO}/main/deploy/install.sh | sudo bash -s -- agent \\" + echo " --server-url https://${DOMAIN} \\" + echo " --enrollment-code YOUR_ONE_TIME_CODE" echo "" } +cmd_domain() { + if [ -z "$COMPONENT" ]; then + COMPONENT="setup" + fi + [ "$COMPONENT" = "setup" ] || error "Usage: serverbee domain setup --domain monitor.example.com --email admin@example.com" + + if [ -z "$DOMAIN" ]; then + if [ "$YES" = true ] || ! [ -t 0 ]; then + error "--domain is required" + fi + read -rp "$(tr_text domain_prompt)" DOMAIN + fi + + if [ -z "$EMAIL" ] && [ "$YES" != true ] && [ -t 0 ]; then + read -rp "$(tr_text email_prompt)" EMAIL + fi + + run_domain_setup_with_plan +} + # ─── Install command ────────────────────────────────────────────────────────── +print_missing_deps_plan() { + collect_missing_deps + if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + echo "$(tr_text plan_pkgs) ${MISSING_DEPS[*]} $(tr_text plan_pkgs_suffix)" + fi +} + +print_common_binary_plan() { + local component="$1" + local os arch filename version + os=$(detect_os) + arch=$(detect_arch) + filename="serverbee-${component}-${os}-${arch}" + version=$(get_latest_version) + + echo "$(tr_text plan_gh_meta)" + if [ -f "${INSTALL_DIR}/serverbee-${component}" ]; then + echo "$(tr_text plan_binary_adopt_pre) ${INSTALL_DIR}/serverbee-${component} $(tr_text plan_binary_adopt_suf)" + else + echo "$(tr_text plan_binary_dl) https://github.com/${REPO}/releases/download/${version}/${filename}" + fi + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" +} + +print_common_docker_plan() { + local component="$1" version + configure_docker_dir + version=$(get_latest_version) + echo "$(tr_text plan_docker_prereq)" + echo "$(tr_text plan_gh_meta)" + echo "$(tr_text plan_docker_image) ghcr.io/zingerlittlebee/serverbee-${component}:$(docker_image_tag "$version")" + echo "$(tr_text plan_cli_script) https://raw.githubusercontent.com/${REPO}/${version}/deploy/install.sh" +} + +print_domain_plan() { + [ -z "$DOMAIN" ] && return + + echo "" + echo "$(tr_text domain_plan_header)" + echo "$(tr_text dp_dns_pre) ${DOMAIN} $(tr_text dp_dns_suf)" + echo "$(tr_text dp_repo)" + echo "$(tr_text dp_key) https://dl.cloudsmith.io/public/caddy/stable/gpg.key" + echo "$(tr_text dp_src) https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" + echo "$(tr_text dp_pkgs)" + echo "$(tr_text dp_caddyfile) ${CADDYFILE}" + echo "$(tr_text dp_bind)" + echo "$(tr_text dp_cookie)" + echo "$(tr_text dp_url) https://${DOMAIN}" +} + +run_domain_preflight_checks() { + [ -z "$DOMAIN" ] && return + + echo "" + echo "$(tr_text preflight)" + validate_domain_name "$DOMAIN" + check_domain_points_here "$DOMAIN" +} + +confirm_domain_setup_plan() { + run_domain_preflight_checks + + echo "" + echo -e "${BOLD}$(tr_text domain_plan_title)${NC}" + echo "" + echo "$(tr_text domain_label) ${DOMAIN}" + [ -n "$EMAIL" ] && echo "$(tr_text email_label) ${EMAIL}" + echo "" + echo "$(tr_text will_add_download)" + print_missing_deps_plan + print_domain_plan + echo "" + + if ! should_prompt; then + info "Proceeding without prompt." + return + fi + + read -rp "$(tr_text start_domain)" confirm + case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) error "Domain setup cancelled." ;; + esac +} + +run_domain_setup_with_plan() { + confirm_domain_setup_plan + check_deps + setup_domain +} + +print_install_plan() { + echo "" + echo -e "${BOLD}$(tr_text install_plan_title)${NC}" + echo "" + echo "$(tr_text plan_component) serverbee-${COMPONENT}" + echo "$(tr_text plan_method) ${METHOD}" + + if [ "$COMPONENT" = "server" ]; then + if [ -n "$DOMAIN" ]; then + echo "$(tr_text plan_access) $(tr_text plan_access_domain_val) (${DOMAIN})" + else + echo "$(tr_text plan_access) $(tr_text plan_access_ip_val)" + fi + else + echo "$(tr_text plan_server_url) ${SERVER_URL}" + fi + + echo "" + echo "$(tr_text will_add_download)" + print_missing_deps_plan + case "${COMPONENT}-${METHOD}" in + server-binary) + print_common_binary_plan "server" + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/server.toml" + echo "$(tr_text plan_data_dir) ${DATA_DIR}" + if has_systemd; then echo "$(tr_text plan_systemd) serverbee-server"; fi + ;; + agent-binary) + print_common_binary_plan "agent" + echo "$(tr_text plan_cfg_file) ${CONFIG_DIR}/agent.toml" + if has_systemd; then echo "$(tr_text plan_systemd) serverbee-agent"; fi + ;; + server-docker) + print_common_docker_plan "server" + echo "$(tr_text plan_cfg_file) $(docker_conf_dir)/server.toml" + echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.server.yml" + echo "$(tr_text plan_docker_volume)" + ;; + agent-docker) + print_common_docker_plan "agent" + echo "$(tr_text plan_cfg_file) $(docker_conf_dir)/agent.toml" + echo "$(tr_text plan_compose_file) ${DOCKER_DIR}/docker-compose.agent.yml" + ;; + esac + print_domain_plan + echo "" +} + +confirm_install_plan() { + run_domain_preflight_checks + print_install_plan + + if ! should_prompt; then + info "Proceeding without prompt." + return + fi + + read -rp "$(tr_text start_install)" confirm + case "$confirm" in + [yY]|[yY][eE][sS]) ;; + *) error "Installation cancelled." ;; + esac +} + +prompt_install_method() { + local choice + + echo "" + if [ "$COMPONENT" = "server" ]; then + echo "$(tr_text server_docker_recommended)" + echo "$(tr_text binary_option)" + echo "" + read -rp "$(tr_text select_method)" choice + case "$choice" in + 1|docker) METHOD="docker" ;; + 2|binary) METHOD="binary" ;; + *) error "Invalid choice: $choice" ;; + esac + else + echo "$(tr_text agent_binary_recommended)" + echo "$(tr_text docker_option)" + echo "" + read -rp "$(tr_text select_method)" choice + case "$choice" in + 1|binary) METHOD="binary" ;; + 2|docker) METHOD="docker" ;; + *) error "Invalid choice: $choice" ;; + esac + fi +} + cmd_install() { # Interactive: prompt for component if not provided if [ -z "$COMPONENT" ]; then echo "" - echo -e "${BOLD}Install${NC}" + echo -e "${BOLD}$(tr_text install_title)${NC}" echo "" - echo " [1] Server — Dashboard & API" - echo " [2] Agent — System metrics collector" + echo "$(tr_text agent_option)" + echo "$(tr_text server_option)" echo "" - read -rp "Select component [1/2]: " choice + read -rp "$(tr_text select_component)" choice case "$choice" in - 1|server) COMPONENT="server" ;; - 2|agent) COMPONENT="agent" ;; + 1|agent) COMPONENT="agent" ;; + 2|server) COMPONENT="server" ;; *) error "Invalid choice: $choice" ;; esac fi @@ -696,21 +1881,14 @@ cmd_install() { # Interactive: prompt for method if not provided if [ -z "$METHOD" ]; then if [ -t 0 ]; then - # Interactive terminal — show menu - echo "" - echo " [1] Binary (recommended)" - echo " [2] Docker" - echo "" - read -rp "Select installation method [1/2]: " choice - case "$choice" in - 1|binary) METHOD="binary" ;; - 2|docker) METHOD="docker" ;; - *) error "Invalid choice: $choice" ;; - esac + prompt_install_method else # Non-interactive (piped) — default to binary METHOD="binary" info "Non-interactive mode detected, defaulting to binary installation." + if [ "$COMPONENT" = "server" ]; then + info "Docker is recommended for Server when Docker is available; pass --method docker to use it." + fi fi fi : "${METHOD:=binary}" @@ -721,12 +1899,12 @@ cmd_install() { echo "" warn "Docker is NOT recommended for Agent" echo "" - echo " ServerBee Agent is portable software:" - echo " - Single binary, no residual files" - echo " - Docker requires --privileged for full metrics" - echo " - Web terminal accesses container, not host" + echo "$(tr_text docker_agent_note)" + echo "$(tr_text docker_agent_note1)" + echo "$(tr_text docker_agent_note2)" + echo "$(tr_text docker_agent_note3)" echo "" - read -rp " Continue with Docker? [y/N]: " confirm + read -rp "$(tr_text docker_continue_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) METHOD="binary"; info "Switched to binary installation." ;; @@ -735,21 +1913,34 @@ cmd_install() { # Prompt for component-specific params if [ "$COMPONENT" = "server" ]; then - if [ -z "$PASSWORD" ] && [ "$YES" != true ]; then + if [ -z "$DOMAIN" ] && [ "$YES" != true ] && [ -t 0 ]; then echo "" - read -rp "Admin password (Enter to skip, auto-generated on first start): " PASSWORD + read -rp "$(tr_text configure_domain)" confirm_domain + if [[ "$confirm_domain" =~ ^[yY] ]]; then + read -rp "$(tr_text domain_prompt)" DOMAIN + read -rp "$(tr_text email_prompt)" EMAIL + fi fi elif [ "$COMPONENT" = "agent" ]; then + if [ -z "$SERVER_URL" ] && [ "$YES" != true ]; then + local default_server_url + default_server_url="http://$(get_local_ip):9527" + read -rp "$(trp server_url_prompt "$default_server_url")" SERVER_URL + SERVER_URL="${SERVER_URL:-$default_server_url}" + fi while [ -z "$SERVER_URL" ]; do if [ "$YES" = true ]; then error "--server-url is required for agent installation"; fi - read -rp "Server URL (e.g., http://10.0.0.1:9527): " SERVER_URL + read -rp "$(trp server_url_prompt "http://$(get_local_ip):9527")" SERVER_URL done while [ -z "$ENROLLMENT_CODE" ]; do if [ "$YES" = true ]; then error "--enrollment-code is required for agent installation (generate a one-time code in the server UI Settings)"; fi - read -rp "Enrollment code: " ENROLLMENT_CODE + read -rp "$(tr_text enrollment_prompt)" ENROLLMENT_CODE done fi + confirm_install_plan + check_deps + info "Installing ${COMPONENT} via ${METHOD}..." case "${COMPONENT}-${METHOD}" in @@ -758,6 +1949,10 @@ cmd_install() { agent-binary) install_binary_agent ;; agent-docker) install_docker_agent ;; esac + + if [ "$COMPONENT" = "server" ] && [ -n "$DOMAIN" ]; then + setup_domain + fi } # ─── Uninstall command ──────────────────────────────────────────────────────── @@ -810,7 +2005,7 @@ uninstall_docker() { done fi rm -f "$compose_file" - rm -f "${CONFIG_DIR}/${component}.toml" + rm -f "$(docker_conf_dir)/${component}.toml" info "Config, data, images, and volumes purged" fi } @@ -819,15 +2014,15 @@ cmd_uninstall() { # Component is required for uninstall if [ -z "$COMPONENT" ]; then echo "" - echo -e "${BOLD}Uninstall${NC}" + echo -e "${BOLD}$(tr_text uninstall_title)${NC}" echo "" - echo " [1] Server" - echo " [2] Agent" + echo "$(tr_text opt_agent)" + echo "$(tr_text opt_server)" echo "" - read -rp "Select component [1/2]: " choice + read -rp "$(tr_text select_component)" choice case "$choice" in - 1|server) COMPONENT="server" ;; - 2|agent) COMPONENT="agent" ;; + 1|agent) COMPONENT="agent" ;; + 2|server) COMPONENT="server" ;; *) error "Invalid choice: $choice" ;; esac fi @@ -845,9 +2040,9 @@ cmd_uninstall() { if [ "$YES" != true ]; then local purge_note="" if [ "$PURGE" = true ]; then - purge_note=" (including config and data)" + purge_note="$(tr_text uninstall_purge_note)" fi - read -rp "Uninstall serverbee-${COMPONENT} (${method})${purge_note}? [y/N]: " confirm + read -rp "$(trp uninstall_confirm "$COMPONENT" "$method" "$purge_note")" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) info "Cancelled."; exit 0 ;; @@ -871,16 +2066,33 @@ cmd_uninstall() { remaining=$(grep -c '"method"' "$META_FILE" 2>/dev/null || true) : "${remaining:=0}" if [ "$remaining" -eq 0 ]; then - rm -f "/usr/local/bin/serverbee" + rm -f "$CLI_PATH" rm -f "$META_FILE" + rm -f "$LANG_CACHE_FILE" + # Drop now-empty layout directories (ignored if anything remains). + rmdir "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_DIR" "$DOCKER_DIR" "$BASE_DIR" 2>/dev/null || true info "All components removed. CLI uninstalled." fi fi if [ "$PURGE" != true ]; then echo "" - echo " Config preserved at: ${CONFIG_DIR}/${COMPONENT}.toml" - echo " To remove all data: re-run with --purge" + echo "$(tr_text uninstall_preserved)" + echo "" + if [ "$method" = "docker" ]; then + local conf_dir + conf_dir="$(docker_conf_dir)" + echo " rm -f ${DOCKER_DIR}/docker-compose.${COMPONENT}.yml" + echo " rm -f ${conf_dir}/${COMPONENT}.toml" + if [ "$COMPONENT" = "server" ]; then + echo " docker volume rm serverbee_serverbee-data" + fi + else + echo " rm -f ${CONFIG_DIR}/${COMPONENT}.toml" + if [ "$COMPONENT" = "server" ]; then + echo " rm -rf ${DATA_DIR}" + fi + fi echo "" fi } @@ -895,7 +2107,7 @@ upgrade_component() { if [ -n "$current_version" ] && [ "$current_version" = "$latest_version" ]; then # Always ensure CLI matches the current release (repairs missing or stale) - install_cli "$latest_version" + refresh_cli_from_release "$latest_version" info "serverbee-${component} is already up to date (${current_version})" return fi @@ -908,7 +2120,7 @@ upgrade_component() { # Confirmation if [ "$YES" != true ]; then - read -rp "Proceed with upgrade? [y/N]: " confirm + read -rp "$(tr_text upgrade_confirm)" confirm case "$confirm" in [yY]|[yY][eE][sS]) ;; *) info "Skipped."; return ;; @@ -922,7 +2134,7 @@ upgrade_component() { esac meta_write "$component" "$method" "$latest_version" - install_cli "$latest_version" + refresh_cli_from_release "$latest_version" info "serverbee-${component} upgraded to ${latest_version}" } @@ -953,6 +2165,8 @@ upgrade_binary() { upgrade_docker() { local component="$1" version="$2" local compose_file="${DOCKER_DIR}/docker-compose.${component}.yml" + local image_tag + image_tag=$(docker_image_tag "$version") if [ ! -f "$compose_file" ]; then error "Compose file not found: $compose_file" @@ -960,7 +2174,7 @@ upgrade_docker() { # Update image tag in compose file local image_base="ghcr.io/zingerlittlebee/serverbee-${component}" - sed -i.bak "s|${image_base}:[^ ]*|${image_base}:${version}|" "$compose_file" && rm -f "${compose_file}.bak" + sed -i.bak "s|${image_base}:[^ ]*|${image_base}:${image_tag}|" "$compose_file" && rm -f "${compose_file}.bak" docker compose -f "$compose_file" pull docker compose -f "$compose_file" up -d @@ -1002,9 +2216,9 @@ status_component() { echo -e "${BOLD}${component^} (${method})${NC}" if [ "$method" = "binary" ]; then - echo " Version: ${version:-unknown}" - echo " Binary: ${INSTALL_DIR}/${service}" - echo " Config: ${CONFIG_DIR}/${component}.toml" + echo "$(tr_text st_version) ${version:-$(tr_text st_unknown)}" + echo "$(tr_text st_binary) ${INSTALL_DIR}/${service}" + echo "$(tr_text st_config) ${CONFIG_DIR}/${component}.toml" if has_systemd; then local status_line @@ -1012,55 +2226,55 @@ status_component() { if [ "$status_line" = "active" ]; then local since since=$(systemctl show "$service" --property=ActiveEnterTimestamp --value 2>/dev/null || echo "") - echo -e " Service: ${GREEN}active (running)${NC} since ${since}" + echo -e "$(tr_text st_service) ${GREEN}$(tr_text st_active)${NC} $(tr_text st_since) ${since}" else - echo -e " Service: ${RED}${status_line}${NC}" + echo -e "$(tr_text st_service) ${RED}${status_line}${NC}" fi - echo " Recent logs:" - journalctl -u "$service" -n 5 --no-pager 2>/dev/null | sed 's/^/ /' || echo " (no logs)" + echo "$(tr_text st_recent_logs)" + journalctl -u "$service" -n 5 --no-pager 2>/dev/null | sed 's/^/ /' || echo "$(tr_text st_no_logs)" fi # Show server_url for agent if [ "$component" = "agent" ] && [ -f "${CONFIG_DIR}/agent.toml" ]; then local srv srv=$(grep "^server_url" "${CONFIG_DIR}/agent.toml" 2>/dev/null | sed 's/.*= *"//;s/".*//' || echo "") - [ -n "$srv" ] && echo " Server: ${srv}" + [ -n "$srv" ] && echo "$(tr_text st_server) ${srv}" fi # Show dashboard URL for server if [ "$component" = "server" ]; then local ip ip=$(get_local_ip) - echo " Dashboard: http://${ip}:9527" + echo "$(tr_text st_dashboard) http://${ip}:9527" fi elif [ "$method" = "docker" ]; then local compose_file="${DOCKER_DIR}/docker-compose.${component}.yml" - echo " Version: ${version:-unknown}" + echo "$(tr_text st_version) ${version:-$(tr_text st_unknown)}" if docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -q "^${service} "; then local container_status container_status=$(docker ps --format '{{.Status}}' --filter "name=^${service}$" 2>/dev/null) - echo -e " Container: ${service} (${GREEN}${container_status}${NC})" + echo -e "$(tr_text st_container) ${service} (${GREEN}${container_status}${NC})" else - echo -e " Container: ${service} (${RED}stopped${NC})" + echo -e "$(tr_text st_container) ${service} (${RED}$(tr_text st_stopped)${NC})" fi local image_tag image_tag=$(docker inspect "${service}" --format '{{.Config.Image}}' 2>/dev/null || echo "unknown") - echo " Image: ${image_tag}" + echo "$(tr_text st_image) ${image_tag}" if [ "$component" = "server" ]; then local ports ports=$(docker port "${service}" 2>/dev/null | head -1 || echo "") - [ -n "$ports" ] && echo " Port: ${ports}" + [ -n "$ports" ] && echo "$(tr_text st_port) ${ports}" local ip ip=$(get_local_ip) - echo " Dashboard: http://${ip}:9527" + echo "$(tr_text st_dashboard) http://${ip}:9527" fi - echo " Recent logs:" - docker logs "${service}" --tail 5 2>/dev/null | sed 's/^/ /' || echo " (no logs)" + echo "$(tr_text st_recent_logs)" + docker logs "${service}" --tail 5 2>/dev/null | sed 's/^/ /' || echo "$(tr_text st_no_logs)" fi } @@ -1070,13 +2284,13 @@ cmd_status() { if [ ${#MANAGED_COMPONENTS[@]} -eq 0 ] && [ ${#UNMANAGED_COMPONENTS[@]} -eq 0 ]; then echo "" - echo "No ServerBee components found. Run 'serverbee install' to get started." + echo "$(tr_text status_none)" echo "" return fi echo "" - echo -e "${BOLD}ServerBee Status${NC}" + echo -e "${BOLD}$(tr_text status_title)${NC}" echo "================" for entry in "${MANAGED_COMPONENTS[@]}"; do @@ -1276,8 +2490,8 @@ cmd_config() { # 1. Check rejected keys if echo "$REJECTED_KEYS" | grep -qw "$key"; then case "$key" in - admin.password) error "Admin password can only be set during initial installation. To change password, use the Dashboard UI." ;; - admin.username) error "Admin username can only be set during initial installation." ;; + admin.password) error "Admin password is not a runtime config. ServerBee generates a one-time first-run password; change it in the Dashboard UI after login." ;; + admin.username) error "Admin username is not a runtime config. Change it during first-login onboarding or in the Dashboard UI." ;; esac fi @@ -1293,13 +2507,13 @@ cmd_config() { local files_to_update=() if [ "$target" = "both" ]; then - meta_has "agent" && files_to_update+=("${CONFIG_DIR}/agent.toml") - meta_has "server" && files_to_update+=("${CONFIG_DIR}/server.toml") + meta_has "agent" && files_to_update+=("$(conf_file_for agent)") + meta_has "server" && files_to_update+=("$(conf_file_for server)") [ ${#files_to_update[@]} -eq 0 ] && error "No managed components found to update log config" elif [ "$target" = "agent" ]; then - files_to_update=("${CONFIG_DIR}/agent.toml") + files_to_update=("$(conf_file_for agent)") elif [ "$target" = "server" ]; then - files_to_update=("${CONFIG_DIR}/server.toml") + files_to_update=("$(conf_file_for server)") fi for file in "${files_to_update[@]}"; do @@ -1326,17 +2540,21 @@ cmd_config() { fi done else - echo "" - echo " Restart service to apply changes?" - read -rp " [y/N]: " confirm - if [[ "$confirm" =~ ^[yY] ]]; then - for entry in "${MANAGED_COMPONENTS[@]}"; do - local comp="${entry%%:*}" - local method="${entry##*:}" - if [[ "$target" == "$comp" || "$target" == "both" ]]; then - cmd_service_single "$comp" "$method" "restart" - fi - done + if [ -t 0 ]; then + echo "" + echo "$(tr_text restart_apply_q)" + read -rp "$(tr_text restart_apply_confirm)" confirm + if [[ "$confirm" =~ ^[yY] ]]; then + for entry in "${MANAGED_COMPONENTS[@]}"; do + local comp="${entry%%:*}" + local method="${entry##*:}" + if [[ "$target" == "$comp" || "$target" == "both" ]]; then + cmd_service_single "$comp" "$method" "restart" + fi + done + fi + else + warn "Non-interactive mode detected; services were not restarted. Re-run with -y to restart automatically, or restart manually." fi fi return @@ -1356,7 +2574,8 @@ cmd_config() { [ ${#targets[@]} -eq 0 ] && error "No managed components found." for comp in "${targets[@]}"; do - local file="${CONFIG_DIR}/${comp}.toml" + local file + file="$(conf_file_for "$comp")" echo "" echo -e "${BOLD}${comp^} config (${file})${NC}" echo "─────────────────────────────────" @@ -1524,19 +2743,20 @@ EOF interactive_menu() { echo "" - echo -e "${BOLD}ServerBee Manager${NC}" + echo -e "${BOLD}$(tr_text manager_title)${NC}" echo "=================" echo "" - echo " [1] Install 安装" - echo " [2] Uninstall 卸载" - echo " [3] Upgrade 升级" - echo " [4] Status 查看状态" - echo " [5] Service 服务控制 (start/stop/restart)" - echo " [6] Config 配置管理" - echo " [7] Env 环境变量" - echo " [0] Exit 退出" + echo "$(tr_text install_menu)" + echo "$(tr_text uninstall_menu)" + echo "$(tr_text upgrade_menu)" + echo "$(tr_text status_menu)" + echo "$(tr_text service_menu)" + echo "$(tr_text config_menu)" + echo "$(tr_text env_menu)" + echo "$(tr_text domain_menu)" + echo "$(tr_text exit_menu)" echo "" - read -rp "Select [0-7]: " choice + read -rp "$(tr_text select_menu)" choice case "$choice" in 1) COMMAND="install" ;; 2) COMMAND="uninstall" ;; @@ -1545,20 +2765,28 @@ interactive_menu() { 5) interactive_service_menu ;; 6) COMMAND="config" ;; 7) COMMAND="env" ;; + 8) COMMAND="domain"; COMPONENT="setup" ;; 0) exit 0 ;; *) error "Invalid choice: $choice" ;; esac require_root + migrate_legacy_layout + case "$COMMAND" in + install|domain) ;; + *) check_deps ;; + esac run_command } interactive_service_menu() { echo "" - echo " [1] Start 启动" - echo " [2] Stop 停止" - echo " [3] Restart 重启" + echo -e "${BOLD}$(tr_text svc_title)${NC}" + echo "" + echo "$(tr_text svc_start)" + echo "$(tr_text svc_stop)" + echo "$(tr_text svc_restart)" echo "" - read -rp "Select [1-3]: " choice + read -rp "$(tr_text svc_select)" choice case "$choice" in 1) COMMAND="start" ;; 2) COMMAND="stop" ;; @@ -1570,6 +2798,8 @@ interactive_service_menu() { # ─── Command dispatch ───────────────────────────────────────────────────────── run_command() { + configure_docker_dir + case "$COMMAND" in install) cmd_install ;; uninstall) cmd_uninstall ;; @@ -1580,30 +2810,52 @@ run_command() { restart) cmd_service restart ;; config) cmd_config ;; env) cmd_env ;; + domain) cmd_domain ;; *) error "Unknown command: $COMMAND" ;; esac } # ─── Main ───────────────────────────────────────────────────────────────────── main() { - # Pre-scan for -y flag so check_deps knows whether to auto-install - for arg in "$@"; do - case "$arg" in --yes|-y) YES=true ;; esac + # Pre-scan for -y and --lang before any prompt or dependency handling. + local args=("$@") + local i + for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[$i]}" in + --yes|-y) + YES=true + ;; + --lang) + if [ $((i + 1)) -ge ${#args[@]} ]; then + error "--lang requires a value" + fi + LANG_CODE="${args[$((i + 1))]}" + normalize_lang + ;; + esac done - check_deps - # Shorthand: first arg not a known command → prepend "install" - if [[ $# -gt 0 ]] && ! echo "$KNOWN_COMMANDS" | grep -qw "$1"; then + if [[ $# -gt 0 ]] && ! is_known_command "$1"; then set -- install "$@" fi if [[ $# -eq 0 ]]; then + select_language interactive_menu else COMMAND="$1"; shift parse_args "$@" + case "$COMMAND" in + install|domain) select_language ;; + *) detect_lang ;; + esac require_root + migrate_legacy_layout + case "$COMMAND" in + install|domain) ;; + *) check_deps ;; + esac run_command fi }