feat(aether-proxy): 支持 Alpine 主机服务安装与 musl 发布 (#291)

* feat(aether-proxy): 支持 Alpine 主机服务安装与 musl 发布

* fix(aether-proxy): address alpine support review findings
This commit is contained in:
RWDai
2026-04-13 17:20:22 +08:00
committed by GitHub
parent 4fd2b4a014
commit 37bb120d20
6 changed files with 627 additions and 209 deletions

View File

@@ -30,6 +30,14 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
os: ubuntu-latest os: ubuntu-latest
use_cross: true use_cross: true
- name: linux-musl-amd64
target: x86_64-unknown-linux-musl
os: ubuntu-latest
use_cross: true
- name: linux-musl-arm64
target: aarch64-unknown-linux-musl
os: ubuntu-latest
use_cross: true
- name: macos-amd64 - name: macos-amd64
target: x86_64-apple-darwin target: x86_64-apple-darwin
os: macos-latest os: macos-latest
@@ -54,7 +62,7 @@ jobs:
- name: Rust cache - name: Rust cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
workspaces: aether-proxy -> target workspaces: apps/aether-proxy -> target
key: ${{ matrix.target }} key: ${{ matrix.target }}
- name: Install cross - name: Install cross
@@ -62,7 +70,7 @@ jobs:
uses: taiki-e/install-action@cross uses: taiki-e/install-action@cross
- name: Build - name: Build
working-directory: aether-proxy working-directory: apps/aether-proxy
shell: bash shell: bash
run: | run: |
if [ "${{ matrix.use_cross }}" = "true" ]; then if [ "${{ matrix.use_cross }}" = "true" ]; then
@@ -75,16 +83,16 @@ jobs:
if: runner.os != 'Windows' if: runner.os != 'Windows'
shell: bash shell: bash
run: | run: |
cd aether-proxy/target/${{ matrix.target }}/release cd target/${{ matrix.target }}/release
chmod +x aether-proxy chmod +x aether-proxy
tar czf ../../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy tar czf ../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy
- name: Package (Windows) - name: Package (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: bash shell: bash
run: | run: |
cd aether-proxy/target/${{ matrix.target }}/release cd target/${{ matrix.target }}/release
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe 7z a ../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
@@ -136,9 +144,9 @@ jobs:
- name: Prepare binaries - name: Prepare binaries
run: | run: |
mkdir -p aether-proxy/build/linux-amd64 aether-proxy/build/linux-arm64 mkdir -p apps/aether-proxy/build/linux-amd64 apps/aether-proxy/build/linux-arm64
tar xzf artifacts/aether-proxy-linux-amd64.tar.gz -C aether-proxy/build/linux-amd64 tar xzf artifacts/aether-proxy-linux-amd64.tar.gz -C apps/aether-proxy/build/linux-amd64
tar xzf artifacts/aether-proxy-linux-arm64.tar.gz -C aether-proxy/build/linux-arm64 tar xzf artifacts/aether-proxy-linux-arm64.tar.gz -C apps/aether-proxy/build/linux-arm64
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -176,7 +184,7 @@ jobs:
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./aether-proxy context: ./apps/aether-proxy
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@@ -197,11 +205,20 @@ jobs:
run: | run: |
VERSION="${TAG#proxy-v}" VERSION="${TAG#proxy-v}"
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}" BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
cd aether-proxy
if [ -d apps/aether-proxy ]; then
PROXY_DIR="apps/aether-proxy"
else
PROXY_DIR="aether-proxy"
fi
cd "$PROXY_DIR"
TABLE="| Platform | Download |\n|----------|----------|\n" TABLE="| Platform | Download |\n|----------|----------|\n"
TABLE+="| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](${BASE}/aether-proxy-linux-amd64.tar.gz) |\n" TABLE+="| Linux x86_64 (GNU) | [aether-proxy-linux-amd64.tar.gz](${BASE}/aether-proxy-linux-amd64.tar.gz) |\n"
TABLE+="| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](${BASE}/aether-proxy-linux-arm64.tar.gz) |\n" TABLE+="| Linux ARM64 (GNU) | [aether-proxy-linux-arm64.tar.gz](${BASE}/aether-proxy-linux-arm64.tar.gz) |\n"
TABLE+="| Linux x86_64 (musl) | [aether-proxy-linux-musl-amd64.tar.gz](${BASE}/aether-proxy-linux-musl-amd64.tar.gz) |\n"
TABLE+="| Linux ARM64 (musl) | [aether-proxy-linux-musl-arm64.tar.gz](${BASE}/aether-proxy-linux-musl-arm64.tar.gz) |\n"
TABLE+="| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](${BASE}/aether-proxy-macos-amd64.tar.gz) |\n" TABLE+="| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](${BASE}/aether-proxy-macos-amd64.tar.gz) |\n"
TABLE+="| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](${BASE}/aether-proxy-macos-arm64.tar.gz) |\n" TABLE+="| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](${BASE}/aether-proxy-macos-arm64.tar.gz) |\n"
TABLE+="| Windows x86_64 | [aether-proxy-windows-amd64.zip](${BASE}/aether-proxy-windows-amd64.zip) |" TABLE+="| Windows x86_64 | [aether-proxy-windows-amd64.zip](${BASE}/aether-proxy-windows-amd64.zip) |"
@@ -217,7 +234,13 @@ jobs:
- name: Commit and push - name: Commit and push
run: | run: |
cd aether-proxy if [ -d apps/aether-proxy ]; then
PROXY_DIR="apps/aether-proxy"
else
PROXY_DIR="aether-proxy"
fi
cd "$PROXY_DIR"
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md git add README.md

View File

@@ -6,6 +6,10 @@ Tunnel 模式下代理节点**无需对外监听端口**,仅需出站连接到
## 安装 ## 安装
`aether-proxy` 会根据宿主机自动选择服务管理器:
- 常规 Linux 发行版:`systemd`
- Alpine Linux`OpenRC`
### Docker Compose 部署 ### Docker Compose 部署
```bash ```bash
@@ -19,13 +23,15 @@ docker compose up -d
<!-- DOWNLOAD_TABLE_START --> <!-- DOWNLOAD_TABLE_START -->
| Platform | Download | | Platform | Download |
|----------|----------| |----------|----------|
| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-amd64.tar.gz) | | Linux x86_64 (GNU) | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-amd64.tar.gz) |
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-arm64.tar.gz) | | Linux ARM64 (GNU) | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-arm64.tar.gz) |
| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-amd64.tar.gz) | | macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-amd64.tar.gz) |
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-arm64.tar.gz) | | macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-arm64.tar.gz) |
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-windows-amd64.zip) | | Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-windows-amd64.zip) |
<!-- DOWNLOAD_TABLE_END --> <!-- DOWNLOAD_TABLE_END -->
上表展示的是最新已发布版本的下载链接。从下一次 `proxy-v*` 发布开始,表格会自动补上 `Linux x86_64 (musl)` / `Linux ARM64 (musl)` 包,供 Alpine 等 musl 系统直接使用。
## 快速开始 ## 快速开始
```bash ```bash
@@ -34,7 +40,7 @@ sudo ./aether-proxy setup
# 2. 日常管理 (勾选 Install Service 作为系统服务的情况下) # 2. 日常管理 (勾选 Install Service 作为系统服务的情况下)
aether-proxy status # 看状态 aether-proxy status # 看状态
aether-proxy logs # 看日志 sudo aether-proxy logs # 看日志
sudo aether-proxy start # 启动服务 sudo aether-proxy start # 启动服务
sudo aether-proxy stop # 停止服务 sudo aether-proxy stop # 停止服务
@@ -47,7 +53,7 @@ sudo aether-proxy setup
sudo aether-proxy uninstall sudo aether-proxy uninstall
``` ```
完成向导后, 配置自动保存到 `aether-proxy.toml`,如果启用了 Install Service将自动注册并启动 systemd 服务 完成向导后, 配置自动保存到 `aether-proxy.toml`,如果启用了 Install Service将自动注册并启动当前系统支持的服务(`systemd``OpenRC`
### 直接运行 ### 直接运行
@@ -132,10 +138,11 @@ sudo aether-proxy uninstall
### 日志落点 ### 日志落点
- 默认 `AETHER_PROXY_LOG_DESTINATION=stdout`,日志交给容器日志驱动或 `journald` - 默认 `AETHER_PROXY_LOG_DESTINATION=stdout`,日志交给容器日志驱动或宿主机服务管理器
- 需要落盘时改成 `file``both`,并设置 `AETHER_PROXY_LOG_DIR`setup TUI 里用 `Save Logs to File` 开关即可 - 需要落盘时改成 `file``both`,并设置 `AETHER_PROXY_LOG_DIR`setup TUI 里用 `Save Logs to File` 开关即可
- 文件日志固定普通 `.log` 文本;默认按天轮换,并保留 7 天 - 文件日志固定普通文本,并支持 `hourly/daily` 轮转;默认按天轮换保留 7 天,最多保留 30 个文件
- `docker compose` 默认保持 `stdout`避免和容器自带日志重复systemd 安装是否额外写文件日志由配置文件决定 - `docker compose` 默认保持 `stdout`,避免和容器自带日志重复;`systemd``OpenRC` 安装时默认会额外打开文件日志到 `/var/log/aether-proxy`
- OpenRC 安装时,`aether-proxy logs` 实际读取 `/var/log/aether-proxy/current.log``/var/log/aether-proxy/error.log`;这些文件通常需要用 `sudo aether-proxy logs` 查看
### 多服务器配置 ### 多服务器配置
@@ -163,4 +170,4 @@ node_name = "jp-proxy-02"
```bash ```bash
git tag proxy-v0.2.0 git tag proxy-v0.2.0
git push origin proxy-v0.2.0 git push origin proxy-v0.2.0
``` ```

View File

@@ -36,12 +36,12 @@ fn build_command() -> clap::Command {
.default_value(DEFAULT_CONFIG), .default_value(DEFAULT_CONFIG),
), ),
) )
.subcommand(clap::Command::new("start").about("Start the systemd service")) .subcommand(clap::Command::new("start").about("Start the installed service"))
.subcommand(clap::Command::new("status").about("Show service status")) .subcommand(clap::Command::new("status").about("Show service status"))
.subcommand(clap::Command::new("logs").about("Tail service logs")) .subcommand(clap::Command::new("logs").about("Tail service logs"))
.subcommand(clap::Command::new("restart").about("Restart the systemd service")) .subcommand(clap::Command::new("restart").about("Restart the installed service"))
.subcommand(clap::Command::new("stop").about("Stop the systemd service")) .subcommand(clap::Command::new("stop").about("Stop the installed service"))
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service")) .subcommand(clap::Command::new("uninstall").about("Uninstall the installed service"))
.subcommand( .subcommand(
clap::Command::new("upgrade") clap::Command::new("upgrade")
.about("Self-upgrade from GitHub releases") .about("Self-upgrade from GitHub releases")
@@ -132,12 +132,17 @@ async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()>
} }
} }
/// Start the proxy server, checking for systemd conflicts first. /// Start the proxy server, checking for managed-service conflicts first.
async fn run_proxy(config: Config) -> anyhow::Result<()> { async fn run_proxy(config: Config) -> anyhow::Result<()> {
// Warn if systemd service is already running (would cause port conflict). // Warn if a managed service is already running (would cause conflicts).
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd). if std::env::var_os("AETHER_PROXY_SERVICE_MANAGER").is_none()
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() { && std::env::var_os("INVOCATION_ID").is_none()
eprintln!("Warning: systemd service is already running."); && setup::service::is_service_active()
{
eprintln!(
"Warning: {} service is already running.",
setup::service::preferred_manager_name()
);
eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:"); eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:");
eprintln!(" ./aether-proxy status / logs / restart / stop"); eprintln!(" ./aether-proxy status / logs / restart / stop");
std::process::exit(1); std::process::exit(1);

View File

@@ -1,29 +1,270 @@
//! Systemd service installation for aether-proxy. //! Service installation and management for `aether-proxy`.
//! //!
//! Called from the setup TUI when the user enables "Install Service". //! Supports the host-native service manager we currently target:
//! The unit file points to the binary and config at their current //! `systemd` on most Linux distributions and `OpenRC` on Alpine.
//! absolute paths -- no files are copied.
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::{Command, ExitStatus, Stdio};
const UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
const SERVICE_NAME: &str = "aether-proxy"; const SERVICE_NAME: &str = "aether-proxy";
/// Whether systemd service installation is possible (systemd present + root). const SYSTEMD_UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
pub fn is_available() -> bool {
is_systemd_available() && is_root() const OPENRC_INIT_PATH: &str = "/etc/init.d/aether-proxy";
const OPENRC_PID_PATH: &str = "/run/aether-proxy.pid";
const OPENRC_LOG_DIR: &str = "/var/log/aether-proxy";
const OPENRC_STDOUT_LOG: &str = "/var/log/aether-proxy/current.log";
const OPENRC_STDERR_LOG: &str = "/var/log/aether-proxy/error.log";
const OPENRC_RUN_BINS: &[&str] = &["/sbin/openrc-run", "/usr/sbin/openrc-run", "openrc-run"];
const OPENRC_SERVICE_BINS: &[&str] = &["/sbin/rc-service", "/usr/sbin/rc-service", "rc-service"];
const OPENRC_UPDATE_BINS: &[&str] = &["/sbin/rc-update", "/usr/sbin/rc-update", "rc-update"];
const OPENRC_SUPERVISE_BINS: &[&str] = &[
"/sbin/supervise-daemon",
"/usr/sbin/supervise-daemon",
"supervise-daemon",
];
const TAIL_BINS: &[&str] = &["/usr/bin/tail", "/bin/tail", "tail"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ServiceManager {
Systemd,
OpenRc,
} }
/// Install aether-proxy as a systemd service. Must be run as root. impl ServiceManager {
pub fn install_service(config_path: &Path) -> anyhow::Result<()> { fn display_name(self) -> &'static str {
if !is_systemd_available() { match self {
anyhow::bail!("systemd not available"); Self::Systemd => "systemd",
Self::OpenRc => "OpenRC",
}
} }
fn unit_path(self) -> &'static str {
match self {
Self::Systemd => SYSTEMD_UNIT_PATH,
Self::OpenRc => OPENRC_INIT_PATH,
}
}
fn is_installed(self) -> bool {
Path::new(self.unit_path()).exists()
}
}
pub fn is_available() -> bool {
detect_service_manager().is_some() && is_root()
}
pub fn preferred_manager_name() -> &'static str {
installed_manager()
.or_else(detect_service_manager)
.map(ServiceManager::display_name)
.unwrap_or("service")
}
pub fn unavailable_hint() -> String {
match detect_service_manager() {
Some(manager) if !is_root() => {
format!(
"requires root with {}, use: sudo aether-proxy setup",
manager.display_name()
)
}
Some(manager) => format!(
"{} is available but service setup is not ready",
manager.display_name()
),
None => "no supported service manager detected (systemd/OpenRC)".into(),
}
}
pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
let manager = detect_service_manager()
.ok_or_else(|| anyhow::anyhow!("no supported service manager detected (systemd/OpenRC)"))?;
if !is_root() { if !is_root() {
anyhow::bail!("root required, use: sudo ./aether-proxy setup"); anyhow::bail!("root required, use: sudo ./aether-proxy setup");
} }
match manager {
ServiceManager::Systemd => install_systemd_service(config_path),
ServiceManager::OpenRc => install_openrc_service(config_path),
}
}
pub(crate) fn is_root() -> bool {
#[cfg(unix)]
{
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}
pub fn is_installed() -> bool {
installed_manager().is_some()
}
pub fn is_service_active() -> bool {
active_service_manager().is_some()
}
pub fn restart_active_service() -> anyhow::Result<()> {
let manager =
active_service_manager().ok_or_else(|| anyhow::anyhow!("no active service detected"))?;
restart_manager(manager)
}
pub fn uninstall_service() -> anyhow::Result<()> {
let Some(manager) = installed_manager() else {
return Ok(());
};
match manager {
ServiceManager::Systemd => uninstall_systemd_service(),
ServiceManager::OpenRc => uninstall_openrc_service(),
}
}
pub fn cmd_status() -> anyhow::Result<()> {
let manager = ensure_service_installed()?;
let status = manager_status(manager)?;
std::process::exit(status.code().unwrap_or(1));
}
pub fn cmd_logs() -> anyhow::Result<()> {
let manager = ensure_service_installed()?;
if manager == ServiceManager::OpenRc {
ensure_openrc_logs_readable()?;
}
let status = match manager {
ServiceManager::Systemd => Command::new("journalctl")
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
.status()?,
ServiceManager::OpenRc => Command::new(tail_bin())
.args(["-n", "100", "-f", OPENRC_STDOUT_LOG, OPENRC_STDERR_LOG])
.status()?,
};
std::process::exit(status.code().unwrap_or(1));
}
pub fn cmd_start() -> anyhow::Result<()> {
let manager = ensure_root_and_service()?;
start_manager(manager)?;
eprintln!(" Service started.");
Ok(())
}
pub fn cmd_restart() -> anyhow::Result<()> {
let manager = ensure_root_and_service()?;
restart_manager(manager)?;
eprintln!(" Service restarted.");
Ok(())
}
pub fn cmd_stop() -> anyhow::Result<()> {
let manager = ensure_root_and_service()?;
stop_manager(manager)?;
eprintln!(" Service stopped.");
Ok(())
}
pub fn cmd_uninstall() -> anyhow::Result<()> {
ensure_root_and_service()?;
uninstall_service()?;
eprintln!();
eprintln!(" Config file, TLS certs, and logs are preserved. Remove manually if needed.");
Ok(())
}
pub(crate) fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
let display = format!("{} {}", program, args.join(" "));
eprintln!(" > {}", display);
let status = Command::new(program).args(args).status()?;
if !status.success() {
anyhow::bail!("command failed: {}", display);
}
Ok(())
}
fn detect_service_manager() -> Option<ServiceManager> {
if is_systemd_available() {
Some(ServiceManager::Systemd)
} else if is_openrc_available() {
Some(ServiceManager::OpenRc)
} else {
None
}
}
fn installed_manager() -> Option<ServiceManager> {
if let Some(manager) = detect_service_manager() {
if manager.is_installed() {
return Some(manager);
}
}
[ServiceManager::Systemd, ServiceManager::OpenRc]
.into_iter()
.find(|manager| manager.is_installed())
}
fn ensure_openrc_logs_readable() -> anyhow::Result<()> {
for path in [OPENRC_STDOUT_LOG, OPENRC_STDERR_LOG] {
match std::fs::File::open(path) {
Ok(_) => {}
Err(err) if err.kind() == ErrorKind::PermissionDenied => {
anyhow::bail!(
"OpenRC logs are stored under {} and usually require root access. Try `sudo ./aether-proxy logs`.",
OPENRC_LOG_DIR
);
}
Err(err) if err.kind() == ErrorKind::NotFound => {
anyhow::bail!(
"OpenRC log file not found at {}. Start the service first or check `./aether-proxy status`.",
path
);
}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
fn active_service_manager() -> Option<ServiceManager> {
if let Some(manager) = installed_manager() {
if manager_is_active(manager) {
return Some(manager);
}
}
[ServiceManager::Systemd, ServiceManager::OpenRc]
.into_iter()
.find(|manager| manager_is_active(*manager))
}
fn ensure_service_installed() -> anyhow::Result<ServiceManager> {
installed_manager().ok_or_else(|| {
anyhow::anyhow!("service not installed, run `sudo ./aether-proxy setup` first")
})
}
fn ensure_root_and_service() -> anyhow::Result<ServiceManager> {
let manager = ensure_service_installed()?;
if !is_root() {
anyhow::bail!("root required, use: sudo ./aether-proxy <command>");
}
Ok(manager)
}
fn install_systemd_service(config_path: &Path) -> anyhow::Result<()> {
let exe_path = std::env::current_exe()?.canonicalize()?; let exe_path = std::env::current_exe()?.canonicalize()?;
let exe_str = exe_path let exe_str = exe_path
.to_str() .to_str()
@@ -40,15 +281,13 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
.to_str() .to_str()
.unwrap_or("/"); .unwrap_or("/");
// Stop existing service if running (ignore errors) if Path::new(SYSTEMD_UNIT_PATH).exists() {
if Path::new(UNIT_PATH).exists() {
eprintln!(" Stopping existing service..."); eprintln!(" Stopping existing service...");
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["stop", SERVICE_NAME]) .args(["stop", SERVICE_NAME])
.status(); .status();
} }
// Write unit file
eprintln!(" Generating systemd unit file..."); eprintln!(" Generating systemd unit file...");
eprintln!(" Binary: {}", exe_str); eprintln!(" Binary: {}", exe_str);
eprintln!(" Config: {}", config_str); eprintln!(" Config: {}", config_str);
@@ -63,6 +302,9 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
Type=simple\n\ Type=simple\n\
WorkingDirectory={working_dir}\n\ WorkingDirectory={working_dir}\n\
Environment=AETHER_PROXY_CONFIG={config_str}\n\ Environment=AETHER_PROXY_CONFIG={config_str}\n\
Environment=AETHER_PROXY_SERVICE_MANAGER=systemd\n\
Environment=AETHER_PROXY_LOG_DESTINATION=both\n\
Environment=AETHER_PROXY_LOG_DIR=/var/log/aether-proxy\n\
ExecStart={exe_str}\n\ ExecStart={exe_str}\n\
Restart=on-failure\n\ Restart=on-failure\n\
RestartSec=5\n\ RestartSec=5\n\
@@ -74,185 +316,322 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
[Install]\n\ [Install]\n\
WantedBy=multi-user.target\n", WantedBy=multi-user.target\n",
); );
std::fs::write(UNIT_PATH, &unit_content)?; std::fs::write(SYSTEMD_UNIT_PATH, &unit_content)?;
// Reload and enable
eprintln!(" Enabling and starting service..."); eprintln!(" Enabling and starting service...");
run_cmd("systemctl", &["daemon-reload"])?; run_cmd("systemctl", &["daemon-reload"])?;
run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?; run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?;
// Verify
eprintln!(); eprintln!();
let output = Command::new("systemctl") if manager_is_active(ServiceManager::Systemd) {
.args(["is-active", SERVICE_NAME])
.output()?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if state == "active" {
eprintln!(" Service started successfully!"); eprintln!(" Service started successfully!");
} else { } else {
eprintln!(" Service state: {} (check logs)", state); eprintln!(" Service state is not active yet. Check `sudo ./aether-proxy logs`.");
} }
eprintln!(); print_post_install_commands();
eprintln!(" Commands:");
eprintln!(" ./aether-proxy status # service status");
eprintln!(" ./aether-proxy logs # tail logs");
eprintln!(" sudo ./aether-proxy restart # restart");
eprintln!(" sudo ./aether-proxy stop # stop");
eprintln!(" sudo ./aether-proxy uninstall # remove service");
eprintln!();
Ok(()) Ok(())
} }
fn is_systemd_available() -> bool { fn install_openrc_service(config_path: &Path) -> anyhow::Result<()> {
Command::new("systemctl") let exe_path = std::env::current_exe()?.canonicalize()?;
.arg("--version") let exe_str = exe_path
.stdout(std::process::Stdio::null()) .to_str()
.stderr(std::process::Stdio::null()) .ok_or_else(|| anyhow::anyhow!("binary path contains invalid UTF-8"))?;
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn is_root() -> bool { let config_abs = std::fs::canonicalize(config_path)?;
#[cfg(unix)] let config_str = config_abs
{ .to_str()
unsafe { libc::geteuid() == 0 } .ok_or_else(|| anyhow::anyhow!("config path contains invalid UTF-8"))?;
}
#[cfg(not(unix))]
{
false
}
}
/// Whether a systemd unit file is currently installed. let working_dir = config_abs
pub fn is_installed() -> bool { .parent()
Path::new(UNIT_PATH).exists() .unwrap_or_else(|| Path::new("/"))
} .to_str()
.unwrap_or("/");
/// Remove the systemd service (called from setup TUI when Install Service is toggled off). if Path::new(OPENRC_INIT_PATH).exists() {
pub fn uninstall_service() -> anyhow::Result<()> { eprintln!(" Stopping existing service...");
if !Path::new(UNIT_PATH).exists() { let _ = Command::new(openrc_service_bin())
return Ok(()); .args([SERVICE_NAME, "stop"])
.status();
} }
std::fs::create_dir_all(OPENRC_LOG_DIR)?;
touch_log(OPENRC_STDOUT_LOG)?;
touch_log(OPENRC_STDERR_LOG)?;
set_mode(OPENRC_LOG_DIR, 0o750)?;
set_mode(OPENRC_STDOUT_LOG, 0o640)?;
set_mode(OPENRC_STDERR_LOG, 0o640)?;
eprintln!(" Generating OpenRC init script...");
eprintln!(" Binary: {}", exe_str);
eprintln!(" Config: {}", config_str);
eprintln!(" WorkDir: {}", working_dir);
let init_content = format!(
r#"#!{}
name={}
description={}
supervisor=supervise-daemon
command={}
directory={}
pidfile={}
output_log_dir={}
output_log={}
error_log={}
supervise_daemon={}
config_env={}
service_manager_env={}
log_destination_env={}
log_dir_env={}
respawn_delay=5
respawn_max=10
respawn_period=60
depend() {{
after net
}}
start_pre() {{
checkpath --directory --mode 0750 "$output_log_dir"
checkpath --file --mode 0640 "$output_log"
checkpath --file --mode 0640 "$error_log"
}}
start() {{
ebegin "Starting ${{RC_SVCNAME}}"
"$supervise_daemon" "${{RC_SVCNAME}}" \
--start "$command" \
--pidfile "$pidfile" \
--chdir "$directory" \
--stdout "$output_log" \
--stderr "$error_log" \
--respawn-delay "$respawn_delay" \
--respawn-max "$respawn_max" \
--respawn-period "$respawn_period" \
--umask 0077 \
--env "$config_env" \
--env "$service_manager_env" \
--env "$log_destination_env" \
--env "$log_dir_env"
eend $?
}}
stop() {{
ebegin "Stopping ${{RC_SVCNAME}}"
"$supervise_daemon" "${{RC_SVCNAME}}" --stop "$command" --pidfile "$pidfile"
eend $?
}}
"#,
openrc_run_bin(),
shell_quote(SERVICE_NAME),
shell_quote("Aether Proxy"),
shell_quote(exe_str),
shell_quote(working_dir),
shell_quote(OPENRC_PID_PATH),
shell_quote(OPENRC_LOG_DIR),
shell_quote(OPENRC_STDOUT_LOG),
shell_quote(OPENRC_STDERR_LOG),
shell_quote(supervise_daemon_bin()),
shell_quote(&format!("AETHER_PROXY_CONFIG={config_str}")),
shell_quote("AETHER_PROXY_SERVICE_MANAGER=openrc"),
shell_quote("AETHER_PROXY_LOG_DESTINATION=both"),
shell_quote(&format!("AETHER_PROXY_LOG_DIR={OPENRC_LOG_DIR}")),
);
std::fs::write(OPENRC_INIT_PATH, &init_content)?;
set_mode(OPENRC_INIT_PATH, 0o755)?;
eprintln!(" Enabling and starting service...");
run_cmd(openrc_update_bin(), &["add", SERVICE_NAME, "default"])?;
run_cmd(openrc_service_bin(), &[SERVICE_NAME, "start"])?;
eprintln!();
if manager_is_active(ServiceManager::OpenRc) {
eprintln!(" Service started successfully!");
} else {
eprintln!(" Service state is not active yet. Check `sudo ./aether-proxy logs`.");
}
print_post_install_commands();
Ok(())
}
fn uninstall_systemd_service() -> anyhow::Result<()> {
eprintln!(" Stopping and removing existing service..."); eprintln!(" Stopping and removing existing service...");
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME]) .args(["disable", "--now", SERVICE_NAME])
.status(); .status();
std::fs::remove_file(UNIT_PATH)?; if Path::new(SYSTEMD_UNIT_PATH).exists() {
eprintln!(" Removed {}", UNIT_PATH); std::fs::remove_file(SYSTEMD_UNIT_PATH)?;
eprintln!(" Removed {}", SYSTEMD_UNIT_PATH);
}
run_cmd("systemctl", &["daemon-reload"])?; run_cmd("systemctl", &["daemon-reload"])?;
eprintln!(" Service uninstalled."); eprintln!(" Service uninstalled.");
eprintln!();
Ok(()) Ok(())
} }
/// Check if the systemd service is currently active. fn uninstall_openrc_service() -> anyhow::Result<()> {
pub fn is_service_active() -> bool { eprintln!(" Stopping and removing existing service...");
std::path::Path::new(UNIT_PATH).exists() let _ = Command::new(openrc_service_bin())
.args([SERVICE_NAME, "stop"])
.status();
let _ = Command::new(openrc_update_bin())
.args(["del", SERVICE_NAME, "default"])
.status();
if Path::new(OPENRC_INIT_PATH).exists() {
std::fs::remove_file(OPENRC_INIT_PATH)?;
eprintln!(" Removed {}", OPENRC_INIT_PATH);
}
eprintln!(" Service uninstalled.");
Ok(())
}
fn start_manager(manager: ServiceManager) -> anyhow::Result<()> {
match manager {
ServiceManager::Systemd => run_cmd("systemctl", &["start", SERVICE_NAME]),
ServiceManager::OpenRc => run_cmd(openrc_service_bin(), &[SERVICE_NAME, "start"]),
}
}
fn stop_manager(manager: ServiceManager) -> anyhow::Result<()> {
match manager {
ServiceManager::Systemd => run_cmd("systemctl", &["stop", SERVICE_NAME]),
ServiceManager::OpenRc => run_cmd(openrc_service_bin(), &[SERVICE_NAME, "stop"]),
}
}
fn restart_manager(manager: ServiceManager) -> anyhow::Result<()> {
match manager {
ServiceManager::Systemd => run_cmd("systemctl", &["restart", SERVICE_NAME]),
ServiceManager::OpenRc => run_cmd(openrc_service_bin(), &[SERVICE_NAME, "restart"]),
}
}
fn manager_status(manager: ServiceManager) -> anyhow::Result<ExitStatus> {
let status = match manager {
ServiceManager::Systemd => Command::new("systemctl")
.args(["status", SERVICE_NAME])
.status()?,
ServiceManager::OpenRc => Command::new(openrc_service_bin())
.args([SERVICE_NAME, "status"])
.status()?,
};
Ok(status)
}
fn manager_is_active(manager: ServiceManager) -> bool {
match manager {
ServiceManager::Systemd => {
Path::new(SYSTEMD_UNIT_PATH).exists()
&& Command::new("systemctl")
.args(["is-active", "--quiet", SERVICE_NAME])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
ServiceManager::OpenRc => {
Path::new(OPENRC_INIT_PATH).exists()
&& Command::new(openrc_service_bin())
.args([SERVICE_NAME, "status"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
}
fn print_post_install_commands() {
eprintln!();
eprintln!(" Commands:");
eprintln!(" ./aether-proxy status # service status");
eprintln!(" sudo ./aether-proxy logs # tail logs");
eprintln!(" sudo ./aether-proxy restart # restart");
eprintln!(" sudo ./aether-proxy stop # stop");
eprintln!(" sudo ./aether-proxy uninstall # remove service");
eprintln!();
}
fn is_systemd_available() -> bool {
Path::new("/run/systemd/system").exists()
&& Command::new("systemctl") && Command::new("systemctl")
.args(["is-active", "--quiet", SERVICE_NAME]) .arg("--version")
.stdout(std::process::Stdio::null()) .stdout(Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(Stdio::null())
.status() .status()
.map(|s| s.success()) .map(|status| status.success())
.unwrap_or(false) .unwrap_or(false)
} }
// ── CLI subcommands (systemd wrappers) ────────────────────────────────────── fn is_openrc_available() -> bool {
(Path::new("/run/openrc").exists() || Path::new("/run/openrc/softlevel").exists())
&& has_absolute_candidate(OPENRC_RUN_BINS)
&& has_absolute_candidate(OPENRC_SERVICE_BINS)
&& has_absolute_candidate(OPENRC_UPDATE_BINS)
&& has_absolute_candidate(OPENRC_SUPERVISE_BINS)
}
fn ensure_service_installed() -> anyhow::Result<()> { fn has_absolute_candidate(candidates: &[&str]) -> bool {
if !std::path::Path::new(UNIT_PATH).exists() { candidates
anyhow::bail!("service not installed, run `sudo ./aether-proxy setup` first"); .iter()
} .any(|candidate| candidate.starts_with('/') && Path::new(candidate).exists())
}
fn openrc_run_bin() -> &'static str {
pick_bin(OPENRC_RUN_BINS)
}
fn openrc_service_bin() -> &'static str {
pick_bin(OPENRC_SERVICE_BINS)
}
fn openrc_update_bin() -> &'static str {
pick_bin(OPENRC_UPDATE_BINS)
}
fn supervise_daemon_bin() -> &'static str {
pick_bin(OPENRC_SUPERVISE_BINS)
}
fn tail_bin() -> &'static str {
pick_bin(TAIL_BINS)
}
fn pick_bin(candidates: &[&'static str]) -> &'static str {
candidates
.iter()
.copied()
.find(|candidate| candidate.starts_with('/') && Path::new(candidate).exists())
.unwrap_or_else(|| candidates[candidates.len() - 1])
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn touch_log(path: &str) -> anyhow::Result<()> {
OpenOptions::new().create(true).append(true).open(path)?;
Ok(()) Ok(())
} }
fn ensure_root_and_service() -> anyhow::Result<()> { fn set_mode(path: &str, mode: u32) -> anyhow::Result<()> {
ensure_service_installed()?; #[cfg(unix)]
if !is_root() { {
anyhow::bail!("root required, use: sudo ./aether-proxy <command>"); use std::os::unix::fs::PermissionsExt;
} let perms = std::fs::Permissions::from_mode(mode);
Ok(()) std::fs::set_permissions(path, perms)?;
}
/// `aether-proxy status` -- show service status.
pub fn cmd_status() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("systemctl")
.args(["status", SERVICE_NAME])
.status()?;
// systemctl status returns non-zero when inactive; that's fine
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy logs` -- tail service logs.
pub fn cmd_logs() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("journalctl")
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
.status()?;
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy start` -- start the service.
pub fn cmd_start() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["start", SERVICE_NAME])?;
eprintln!(" Service started.");
Ok(())
}
/// `aether-proxy restart` -- restart the service.
pub fn cmd_restart() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["restart", SERVICE_NAME])?;
eprintln!(" Service restarted.");
Ok(())
}
/// `aether-proxy stop` -- stop the service.
pub fn cmd_stop() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["stop", SERVICE_NAME])?;
eprintln!(" Service stopped.");
Ok(())
}
/// `aether-proxy uninstall` -- disable and remove the systemd service.
pub fn cmd_uninstall() -> anyhow::Result<()> {
ensure_root_and_service()?;
eprintln!(" Stopping and disabling service...");
let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
if std::path::Path::new(UNIT_PATH).exists() {
std::fs::remove_file(UNIT_PATH)?;
eprintln!(" Removed {}", UNIT_PATH);
} }
run_cmd("systemctl", &["daemon-reload"])?; #[cfg(not(unix))]
eprintln!(" Service uninstalled."); let _ = (path, mode);
eprintln!();
eprintln!(" Config file and TLS certs are preserved. Remove manually if needed.");
Ok(()) Ok(())
} }
pub(crate) fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
let display = format!("{} {}", program, args.join(" "));
eprintln!(" > {}", display);
let status = Command::new(program).args(args).status()?;
if !status.success() {
anyhow::bail!("command failed: {}", display);
}
Ok(())
}

View File

@@ -28,7 +28,7 @@ use crate::config::{
/// Outcome of the setup wizard, returned to the caller. /// Outcome of the setup wizard, returned to the caller.
pub enum SetupOutcome { pub enum SetupOutcome {
/// Config saved; systemd service installed and started. /// Config saved and the selected host service was installed.
ServiceInstalled, ServiceInstalled,
/// Config saved; no service -- caller should start the proxy directly. /// Config saved; no service -- caller should start the proxy directly.
ReadyToRun(PathBuf), ReadyToRun(PathBuf),
@@ -148,7 +148,7 @@ impl App {
.into(), .into(),
kind: FieldKind::Bool, kind: FieldKind::Bool,
required: false, required: false,
help: "Install as systemd service (requires root) -- Enter to toggle", help: "Install as managed service (requires root) -- Enter to toggle",
}, },
Field { Field {
label: "Log Level", label: "Log Level",
@@ -532,11 +532,8 @@ impl App {
&& toggled == "true" && toggled == "true"
&& !super::service::is_available() && !super::service::is_available()
{ {
self.message = Some(( self.message =
"requires root with systemd, use: sudo aether-proxy setup".into(), Some((super::service::unavailable_hint(), Instant::now(), true));
Instant::now(),
true,
));
} else { } else {
self.selected_field_mut().value = toggled.into(); self.selected_field_mut().value = toggled.into();
self.modified = true; self.modified = true;

View File

@@ -1,7 +1,8 @@
//! Self-upgrade for aether-proxy. //! Self-upgrade support for `aether-proxy`.
//! //!
//! Downloads a release from GitHub, verifies SHA256 checksum, and atomically //! Downloads a release from GitHub, verifies the SHA256 checksum, replaces the
//! replaces the running binary. Restarts the systemd service if active. //! running binary atomically, and restarts the active managed service when
//! applicable.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -23,7 +24,14 @@ struct GithubRelease {
// ── Platform detection ─────────────────────────────────────────────────────── // ── Platform detection ───────────────────────────────────────────────────────
fn detect_platform() -> &'static str { fn detect_platform() -> &'static str {
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") { if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") && cfg!(target_env = "musl") {
"linux-musl-amd64"
} else if cfg!(target_os = "linux")
&& cfg!(target_arch = "aarch64")
&& cfg!(target_env = "musl")
{
"linux-musl-arm64"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
"linux-amd64" "linux-amd64"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") { } else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
"linux-arm64" "linux-arm64"
@@ -361,34 +369,33 @@ async fn execute_upgrade(
match restart_mode { match restart_mode {
RestartMode::BestEffort => { RestartMode::BestEffort => {
// Restart systemd service if running.
// Use best-effort: binary is already replaced, so a restart failure should // Use best-effort: binary is already replaced, so a restart failure should
// not abort the whole upgrade -- the user can restart manually. // not abort the whole upgrade -- the user can restart manually.
if super::service::is_service_active() { if super::service::is_service_active() {
if super::service::is_root() { if super::service::is_root() {
eprintln!(" Restarting systemd service..."); eprintln!(" Restarting managed service...");
match super::service::run_cmd("systemctl", &["restart", "aether-proxy"]) { match super::service::restart_active_service() {
Ok(()) => eprintln!(" Service restarted."), Ok(()) => eprintln!(" Service restarted."),
Err(e) => { Err(e) => {
eprintln!(" WARNING: failed to restart service: {}", e); eprintln!(" WARNING: failed to restart service: {}", e);
eprintln!(" Run manually: sudo systemctl restart aether-proxy"); eprintln!(" Run manually: sudo aether-proxy restart");
} }
} }
} else { } else {
eprintln!(" Systemd service is active, but restart requires root."); eprintln!(" Managed service is active, but restart requires root.");
eprintln!(" Run: sudo systemctl restart aether-proxy"); eprintln!(" Run: sudo aether-proxy restart");
eprintln!(" Skipping restart."); eprintln!(" Skipping restart.");
} }
} else { } else {
eprintln!(" No active systemd service detected, skipping restart."); eprintln!(" No active service detected, skipping restart.");
} }
} }
RestartMode::Required => { RestartMode::Required => {
if !super::service::is_root() { if !super::service::is_root() {
anyhow::bail!("automatic upgrade requires root privileges"); anyhow::bail!("automatic upgrade requires root privileges");
} }
eprintln!(" Restarting systemd service..."); eprintln!(" Restarting managed service...");
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?; super::service::restart_active_service()?;
eprintln!(" Service restarted."); eprintln!(" Service restarted.");
} }
} }
@@ -409,8 +416,8 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
/// Perform automatic upgrade to a specific version. /// Perform automatic upgrade to a specific version.
/// ///
/// This path is designed for server-pushed upgrades in systemd/root scenarios: /// This path is used for server-pushed upgrades: it requires root and expects
/// it requires root and requires a successful `systemctl restart aether-proxy`. /// the currently active managed service to restart successfully.
pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> { pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> {
execute_upgrade(Some(version), true, RestartMode::Required).await execute_upgrade(Some(version), true, RestartMode::Required).await
} }