Compare commits

...

11 Commits

Author SHA1 Message Date
shaw
caae7e4603 feat: 改进安装脚本的交互体验和自动化流程
- 修复 curl | bash 管道模式下无法交互式输入的问题
  - 使用 /dev/tty 检测终端可用性替代 stdin 检测
  - 所有 read 命令从 /dev/tty 读取用户输入
- 安装完成后自动启动服务和启用开机自启
- 使用 ipinfo.io API 获取公网 IP 用于显示访问地址
- 简化安装完成后的输出信息
2025-12-18 20:53:29 +08:00
shaw
a26db8b3e2 fix: 修复前端页面刷新时偶发空白渲染的竞态条件问题
使用 router.isReady() 等待路由器完成初始导航后再挂载应用,
避免 RouterView 在路由未就绪时渲染空的 Comment 节点。
2025-12-18 20:45:56 +08:00
shaw
8e81e395b3 refactor: 使用行业标准方案重构服务重启逻辑
重构内容:
- 移除复杂的 sudo systemctl restart 方案
- 改用 os.Exit(0) + systemd Restart=always 的标准做法
- 删除 sudoers 配置及相关代码
- 删除 sub2api-sudoers 文件

优势:
- 代码从 85+ 行简化到 47 行
- 无需 sudo 权限配置
- 无需特殊用户 shell 配置
- 更简单、更可靠
- 符合行业最佳实践(Docker/K8s 等均采用此方案)

工作原理:
- 服务调用 os.Exit(0) 优雅退出
- systemd 检测到退出后自动重启(Restart=always)
2025-12-18 20:32:24 +08:00
shaw
f0e89992f7 fix: 使用 setsid 确保重启命令独立于父进程执行
问题原因:
- cmd.Start() 启动的子进程与父进程在同一会话中
- 当 systemctl restart 发送 SIGTERM 给父进程时
- 子进程可能也会被终止,导致重启命令无法完成

修复内容:
- 使用 setsid 创建新会话,子进程完全独立于父进程
- 分离标准输入/输出/错误流
- 确保即使父进程被 kill,重启命令仍能执行完成
2025-12-18 20:00:53 +08:00
shaw
4eaa0cf14a fix: 使用完整路径执行 sudo 和 systemctl 命令
问题原因:
- systemd 服务的 PATH 环境变量可能受限
- 直接使用 "sudo" 可能找不到可执行文件

修复内容:
- 添加 findExecutable 函数动态查找可执行文件路径
- 先尝试 exec.LookPath,再检查常见系统路径
- 添加日志显示实际使用的路径,方便调试
- 兼容不同 Linux 发行版的路径差异
2025-12-18 19:58:25 +08:00
shaw
e9ec2280ec fix: 修复 sudo 在非交互模式下无法执行的问题
问题原因:
- sudo 命令没有 -n 选项
- 在后台服务中,sudo 会尝试从终端读取密码
- 由于没有终端,命令静默失败

修复内容:
- 添加 sudo -n 选项强制非交互模式
- 如果需要密码会立即失败并返回错误,而不是挂起
2025-12-18 19:37:41 +08:00
Wesley Liddick
bb7bfb6980 Merge pull request #1 from 7836246/fix/concurrent-proxy-race-condition
fix: 修复并发请求时共享httpClient.Transport导致的竞态条件
2025-12-18 06:37:22 -05:00
shaw
b66f97c100 fix: 修复 install.sh 优先使用旧 sudoers 文件的问题
问题原因:
- install.sh 优先从 tar.gz 复制 sudoers 文件
- 旧版 Release 中的 sudoers 文件没有 /usr/bin/systemctl 路径
- 即使脚本更新了,仍然会使用旧的配置

修复内容:
- 移除对 tar.gz 中 sudoers 文件的依赖
- 总是使用脚本中内嵌的最新配置
- 确保新版脚本立即生效,无需等待新 Release
2025-12-18 19:27:47 +08:00
shaw
b51ad0d893 fix: 修复 sudoers 中 systemctl 路径不兼容的问题
问题原因:
- sudoers 只配置了 /bin/systemctl 路径
- 部分系统(如 Ubuntu 22.04+)的 systemctl 位于 /usr/bin/systemctl
- 路径不匹配导致 sudo 仍然需要密码

修复内容:
- 同时支持 /bin/systemctl 和 /usr/bin/systemctl 两个路径
- 兼容 Debian/Ubuntu 和 RHEL/CentOS 等不同发行版
2025-12-18 19:17:05 +08:00
shaw
4eb22d8ee9 fix: 修复服务用户 shell 导致无法执行 sudo 重启的问题
问题原因:
- 服务用户 sub2api 的 shell 被设置为 /bin/false
- 导致无法执行 sudo systemctl restart 命令
- 安装/升级后服务无法自动重启

修复内容:
- 新安装时使用 /bin/sh 替代 /bin/false
- 升级时自动检测并修复旧版本用户的 shell 配置
- 修复失败时给出警告和手动修复命令,不中断安装流程
2025-12-18 19:07:33 +08:00
江西小徐
2392e7cf99 fix: 修复并发请求时共享httpClient.Transport导致的竞态条件
问题描述:
当多个请求并发执行且使用不同代理配置时,它们会同时修改共享的
s.httpClient.Transport,导致请求可能使用错误的代理(数据泄露风险)
或意外失败。

修复方案:
为需要代理的请求创建独立的http.Client,而不是修改共享的httpClient.Transport。

改动内容:
- 新增 buildUpstreamRequestResult 结构体,返回请求和可选的独立client
- 修改 buildUpstreamRequest 方法,配置代理时创建独立client
- 更新 Forward 方法,根据是否有代理选择合适的client
2025-12-18 18:14:48 +08:00
6 changed files with 174 additions and 125 deletions

3
.gitignore vendored
View File

@@ -91,4 +91,5 @@ backend/data/
# =================== # ===================
tests tests
CLAUDE.md CLAUDE.md
.claude .claude
scripts

View File

@@ -1,43 +1,39 @@
package sysutil package sysutil
import ( import (
"fmt"
"log" "log"
"os/exec" "os"
"runtime" "runtime"
"time"
) )
const serviceName = "sub2api" // RestartService triggers a service restart by gracefully exiting.
// RestartService triggers a service restart via systemd.
// //
// IMPORTANT: This function initiates the restart and returns immediately. // This relies on systemd's Restart=always configuration to automatically
// The actual restart happens asynchronously - the current process will be killed // restart the service after it exits. This is the industry-standard approach:
// by systemd and a new process will be started. // - Simple and reliable
// // - No sudo permissions needed
// We use Start() instead of Run() because: // - No complex process management
// - systemctl restart will kill the current process first // - Leverages systemd's native restart capability
// - Run() waits for completion, but the process dies before completion
// - Start() spawns the command independently, allowing systemd to handle the full cycle
// //
// Prerequisites: // Prerequisites:
// - Linux OS with systemd // - Linux OS with systemd
// - NOPASSWD sudo access configured (install.sh creates /etc/sudoers.d/sub2api) // - Service configured with Restart=always in systemd unit file
func RestartService() error { func RestartService() error {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return fmt.Errorf("systemd restart only available on Linux") log.Println("Service restart via exit only works on Linux with systemd")
return nil
} }
log.Println("Initiating service restart...") log.Println("Initiating service restart by graceful exit...")
log.Println("systemd will automatically restart the service (Restart=always)")
// The sub2api user has NOPASSWD sudo access for systemctl commands // Give a moment for logs to flush and response to be sent
// (configured by install.sh in /etc/sudoers.d/sub2api). go func() {
cmd := exec.Command("sudo", "systemctl", "restart", serviceName) time.Sleep(100 * time.Millisecond)
if err := cmd.Start(); err != nil { os.Exit(0)
return fmt.Errorf("failed to initiate service restart: %w", err) }()
}
log.Println("Service restart initiated successfully")
return nil return nil
} }

View File

@@ -35,25 +35,25 @@ const (
// allowedHeaders 白名单headers参考CRS项目 // allowedHeaders 白名单headers参考CRS项目
var allowedHeaders = map[string]bool{ var allowedHeaders = map[string]bool{
"accept": true, "accept": true,
"x-stainless-retry-count": true, "x-stainless-retry-count": true,
"x-stainless-timeout": true, "x-stainless-timeout": true,
"x-stainless-lang": true, "x-stainless-lang": true,
"x-stainless-package-version": true, "x-stainless-package-version": true,
"x-stainless-os": true, "x-stainless-os": true,
"x-stainless-arch": true, "x-stainless-arch": true,
"x-stainless-runtime": true, "x-stainless-runtime": true,
"x-stainless-runtime-version": true, "x-stainless-runtime-version": true,
"x-stainless-helper-method": true, "x-stainless-helper-method": true,
"anthropic-dangerous-direct-browser-access": true, "anthropic-dangerous-direct-browser-access": true,
"anthropic-version": true, "anthropic-version": true,
"x-app": true, "x-app": true,
"anthropic-beta": true, "anthropic-beta": true,
"accept-language": true, "accept-language": true,
"sec-fetch-mode": true, "sec-fetch-mode": true,
"accept-encoding": true, "accept-encoding": true,
"user-agent": true, "user-agent": true,
"content-type": true, "content-type": true,
} }
// ClaudeUsage 表示Claude API返回的usage信息 // ClaudeUsage 表示Claude API返回的usage信息
@@ -418,13 +418,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
} }
// 构建上游请求 // 构建上游请求
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType) upstreamResult, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 选择使用的client如果有代理则使用独立的client否则使用共享的httpClient
httpClient := s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
// 发送请求 // 发送请求
resp, err := s.httpClient.Do(upstreamReq) resp, err := httpClient.Do(upstreamResult.Request)
if err != nil { if err != nil {
return nil, fmt.Errorf("upstream request failed: %w", err) return nil, fmt.Errorf("upstream request failed: %w", err)
} }
@@ -437,11 +443,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
if err != nil { if err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err) return nil, fmt.Errorf("token refresh failed: %w", err)
} }
upstreamReq, err = s.buildUpstreamRequest(ctx, c, account, body, token, tokenType) upstreamResult, err = s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err = s.httpClient.Do(upstreamReq) // 重试时也需要使用正确的client
httpClient = s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
resp, err = httpClient.Do(upstreamResult.Request)
if err != nil { if err != nil {
return nil, fmt.Errorf("retry request failed: %w", err) return nil, fmt.Errorf("retry request failed: %w", err)
} }
@@ -480,7 +491,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
}, nil }, nil
} }
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token, tokenType string) (*http.Request, error) { // buildUpstreamRequestResult contains the request and optional custom client for proxy
type buildUpstreamRequestResult struct {
Request *http.Request
Client *http.Client // nil means use default s.httpClient
}
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token, tokenType string) (*buildUpstreamRequestResult, error) {
// 确定目标URL // 确定目标URL
targetURL := claudeAPIURL targetURL := claudeAPIURL
if account.Type == model.AccountTypeApiKey { if account.Type == model.AccountTypeApiKey {
@@ -549,7 +566,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
req.Header.Set("anthropic-beta", s.getBetaHeader(body, c.GetHeader("anthropic-beta"))) req.Header.Set("anthropic-beta", s.getBetaHeader(body, c.GetHeader("anthropic-beta")))
} }
// 配置代理 // 配置代理 - 创建独立的client避免并发修改共享httpClient
var customClient *http.Client
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL := account.Proxy.URL() proxyURL := account.Proxy.URL()
if proxyURL != "" { if proxyURL != "" {
@@ -566,12 +584,18 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: responseHeaderTimeout, ResponseHeaderTimeout: responseHeaderTimeout,
} }
s.httpClient.Transport = transport // 创建独立的client避免并发时修改共享的s.httpClient.Transport
customClient = &http.Client{
Transport: transport,
}
} }
} }
} }
return req, nil return &buildUpstreamRequestResult{
Request: req,
Client: customClient,
}, nil
} }
// getBetaHeader 处理anthropic-beta header // getBetaHeader 处理anthropic-beta header

View File

@@ -73,9 +73,6 @@ declare -A MSG_ZH=(
["dirs_configured"]="目录配置完成" ["dirs_configured"]="目录配置完成"
["installing_service"]="正在安装 systemd 服务..." ["installing_service"]="正在安装 systemd 服务..."
["service_installed"]="systemd 服务已安装" ["service_installed"]="systemd 服务已安装"
["setting_up_sudoers"]="正在配置 sudoers..."
["sudoers_configured"]="sudoers 配置完成"
["sudoers_failed"]="sudoers 验证失败,已移除文件"
["ready_for_setup"]="准备就绪,可以启动设置向导" ["ready_for_setup"]="准备就绪,可以启动设置向导"
# Completion # Completion
@@ -131,6 +128,15 @@ declare -A MSG_ZH=(
["server_port_hint"]="建议使用 1024-65535 之间的端口" ["server_port_hint"]="建议使用 1024-65535 之间的端口"
["server_config_summary"]="服务器配置" ["server_config_summary"]="服务器配置"
["invalid_port"]="无效端口号,请输入 1-65535 之间的数字" ["invalid_port"]="无效端口号,请输入 1-65535 之间的数字"
# Service management
["starting_service"]="正在启动服务..."
["service_started"]="服务已启动"
["service_start_failed"]="服务启动失败,请检查日志"
["enabling_autostart"]="正在设置开机自启..."
["autostart_enabled"]="开机自启已启用"
["getting_public_ip"]="正在获取公网 IP..."
["public_ip_failed"]="无法获取公网 IP使用本地 IP"
) )
# English strings # English strings
@@ -173,9 +179,6 @@ declare -A MSG_EN=(
["dirs_configured"]="Directories configured" ["dirs_configured"]="Directories configured"
["installing_service"]="Installing systemd service..." ["installing_service"]="Installing systemd service..."
["service_installed"]="Systemd service installed" ["service_installed"]="Systemd service installed"
["setting_up_sudoers"]="Setting up sudoers..."
["sudoers_configured"]="Sudoers configured"
["sudoers_failed"]="Sudoers validation failed, removing file"
["ready_for_setup"]="Ready for Setup Wizard" ["ready_for_setup"]="Ready for Setup Wizard"
# Completion # Completion
@@ -231,6 +234,15 @@ declare -A MSG_EN=(
["server_port_hint"]="Recommended range: 1024-65535" ["server_port_hint"]="Recommended range: 1024-65535"
["server_config_summary"]="Server configuration" ["server_config_summary"]="Server configuration"
["invalid_port"]="Invalid port number, please enter a number between 1-65535" ["invalid_port"]="Invalid port number, please enter a number between 1-65535"
# Service management
["starting_service"]="Starting service..."
["service_started"]="Service started"
["service_start_failed"]="Service failed to start, please check logs"
["enabling_autostart"]="Enabling auto-start on boot..."
["autostart_enabled"]="Auto-start enabled"
["getting_public_ip"]="Getting public IP..."
["public_ip_failed"]="Failed to get public IP, using local IP"
) )
# Get message based on current language # Get message based on current language
@@ -260,9 +272,11 @@ print_error() {
echo -e "${RED}[$(msg 'error')]${NC} $1" echo -e "${RED}[$(msg 'error')]${NC} $1"
} }
# Check if running interactively (stdin is a terminal) # Check if running interactively (can access terminal)
# When piped (curl | bash), stdin is not a terminal, but /dev/tty may still be available
is_interactive() { is_interactive() {
[ -t 0 ] # Check if /dev/tty is available (works even when piped)
[ -e /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]
} }
# Select language # Select language
@@ -282,7 +296,7 @@ select_language() {
echo " 2) $(msg 'lang_en')" echo " 2) $(msg 'lang_en')"
echo "" echo ""
read -p "$(msg 'enter_choice'): " lang_input read -p "$(msg 'enter_choice'): " lang_input < /dev/tty
case "$lang_input" in case "$lang_input" in
2|en|EN|english|English) 2|en|EN|english|English)
@@ -323,7 +337,7 @@ configure_server() {
# Server host # Server host
echo -e "${YELLOW}$(msg 'server_host_hint')${NC}" echo -e "${YELLOW}$(msg 'server_host_hint')${NC}"
read -p "$(msg 'server_host_prompt') [${SERVER_HOST}]: " input_host read -p "$(msg 'server_host_prompt') [${SERVER_HOST}]: " input_host < /dev/tty
if [ -n "$input_host" ]; then if [ -n "$input_host" ]; then
SERVER_HOST="$input_host" SERVER_HOST="$input_host"
fi fi
@@ -333,7 +347,7 @@ configure_server() {
# Server port # Server port
echo -e "${YELLOW}$(msg 'server_port_hint')${NC}" echo -e "${YELLOW}$(msg 'server_port_hint')${NC}"
while true; do while true; do
read -p "$(msg 'server_port_prompt') [${SERVER_PORT}]: " input_port read -p "$(msg 'server_port_prompt') [${SERVER_PORT}]: " input_port < /dev/tty
if [ -z "$input_port" ]; then if [ -z "$input_port" ]; then
# Use default # Use default
break break
@@ -483,9 +497,24 @@ download_and_extract() {
create_user() { create_user() {
if id "$SERVICE_USER" &>/dev/null; then if id "$SERVICE_USER" &>/dev/null; then
print_info "$(msg 'user_exists'): $SERVICE_USER" print_info "$(msg 'user_exists'): $SERVICE_USER"
# Fix: Ensure existing user has /bin/sh shell for sudo to work
# Previous versions used /bin/false which prevents sudo execution
local current_shell
current_shell=$(getent passwd "$SERVICE_USER" 2>/dev/null | cut -d: -f7)
if [ "$current_shell" = "/bin/false" ] || [ "$current_shell" = "/sbin/nologin" ]; then
print_info "Fixing user shell for sudo compatibility..."
if usermod -s /bin/sh "$SERVICE_USER" 2>/dev/null; then
print_success "User shell updated to /bin/sh"
else
print_warning "Failed to update user shell. Service restart may not work automatically."
print_warning "Manual fix: sudo usermod -s /bin/sh $SERVICE_USER"
fi
fi
else else
print_info "$(msg 'creating_user') $SERVICE_USER..." print_info "$(msg 'creating_user') $SERVICE_USER..."
useradd -r -s /bin/false -d "$INSTALL_DIR" "$SERVICE_USER" # Use /bin/sh instead of /bin/false to allow sudo execution
# The user still cannot login interactively (no password set)
useradd -r -s /bin/sh -d "$INSTALL_DIR" "$SERVICE_USER"
print_success "$(msg 'user_created')" print_success "$(msg 'user_created')"
fi fi
} }
@@ -506,35 +535,6 @@ setup_directories() {
print_success "$(msg 'dirs_configured')" print_success "$(msg 'dirs_configured')"
} }
# Setup sudoers for service restart
setup_sudoers() {
print_info "$(msg 'setting_up_sudoers')"
# Check if sudoers file exists in install dir
if [ -f "$INSTALL_DIR/sub2api-sudoers" ]; then
cp "$INSTALL_DIR/sub2api-sudoers" /etc/sudoers.d/sub2api
else
# Create sudoers file
cat > /etc/sudoers.d/sub2api << 'EOF'
# Sudoers configuration for Sub2API
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl start sub2api
EOF
fi
# Set correct permissions (required for sudoers files)
chmod 440 /etc/sudoers.d/sub2api
# Validate sudoers file
if visudo -c -f /etc/sudoers.d/sub2api &>/dev/null; then
print_success "$(msg 'sudoers_configured')"
else
print_warning "$(msg 'sudoers_failed')"
rm -f /etc/sudoers.d/sub2api
fi
}
# Install systemd service # Install systemd service
install_service() { install_service() {
print_info "$(msg 'installing_service')" print_info "$(msg 'installing_service')"
@@ -586,13 +586,61 @@ prepare_for_setup() {
print_success "$(msg 'ready_for_setup')" print_success "$(msg 'ready_for_setup')"
} }
# Get public IP address
get_public_ip() {
print_info "$(msg 'getting_public_ip')"
# Try to get public IP from ipinfo.io
local response
response=$(curl -s --connect-timeout 5 --max-time 10 "https://ipinfo.io/json" 2>/dev/null)
if [ -n "$response" ]; then
# Extract IP from JSON response using grep and sed (no jq dependency)
PUBLIC_IP=$(echo "$response" | grep -o '"ip": *"[^"]*"' | sed 's/"ip": *"\([^"]*\)"/\1/')
if [ -n "$PUBLIC_IP" ]; then
print_success "Public IP: $PUBLIC_IP"
return 0
fi
fi
# Fallback to local IP
print_warning "$(msg 'public_ip_failed')"
PUBLIC_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
return 1
}
# Start service
start_service() {
print_info "$(msg 'starting_service')"
if systemctl start sub2api; then
print_success "$(msg 'service_started')"
return 0
else
print_error "$(msg 'service_start_failed')"
print_info "sudo journalctl -u sub2api -n 50"
return 1
fi
}
# Enable service auto-start
enable_autostart() {
print_info "$(msg 'enabling_autostart')"
if systemctl enable sub2api 2>/dev/null; then
print_success "$(msg 'autostart_enabled')"
return 0
else
print_warning "Failed to enable auto-start"
return 1
fi
}
# Print completion message # Print completion message
print_completion() { print_completion() {
local ip_addr # Use PUBLIC_IP which was set by get_public_ip()
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
# Determine display address # Determine display address
local display_host="$ip_addr" local display_host="${PUBLIC_IP:-YOUR_SERVER_IP}"
if [ "$SERVER_HOST" = "127.0.0.1" ]; then if [ "$SERVER_HOST" = "127.0.0.1" ]; then
display_host="127.0.0.1" display_host="127.0.0.1"
fi fi
@@ -606,21 +654,9 @@ print_completion() {
echo "$(msg 'server_config_summary'): ${SERVER_HOST}:${SERVER_PORT}" echo "$(msg 'server_config_summary'): ${SERVER_HOST}:${SERVER_PORT}"
echo "" echo ""
echo "==============================================" echo "=============================================="
echo " $(msg 'next_steps')" echo " $(msg 'step4_open_wizard')"
echo "==============================================" echo "=============================================="
echo "" echo ""
echo " 1. $(msg 'step1_check_services')"
echo " sudo systemctl status postgresql"
echo " sudo systemctl status redis"
echo ""
echo " 2. $(msg 'step2_start_service')"
echo " sudo systemctl start sub2api"
echo ""
echo " 3. $(msg 'step3_enable_autostart')"
echo " sudo systemctl enable sub2api"
echo ""
echo " 4. $(msg 'step4_open_wizard')"
echo ""
print_info " http://${display_host}:${SERVER_PORT}" print_info " http://${display_host}:${SERVER_PORT}"
echo "" echo ""
echo " $(msg 'wizard_guide')" echo " $(msg 'wizard_guide')"
@@ -687,7 +723,7 @@ uninstall() {
exit 1 exit 1
fi fi
else else
read -p "$(msg 'are_you_sure') " -n 1 -r read -p "$(msg 'are_you_sure') " -n 1 -r < /dev/tty
echo echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "$(msg 'uninstall_cancelled')" print_info "$(msg 'uninstall_cancelled')"
@@ -701,7 +737,6 @@ uninstall() {
print_info "$(msg 'removing_files')" print_info "$(msg 'removing_files')"
rm -f /etc/systemd/system/sub2api.service rm -f /etc/systemd/system/sub2api.service
rm -f /etc/sudoers.d/sub2api
systemctl daemon-reload systemctl daemon-reload
print_info "$(msg 'removing_install_dir')" print_info "$(msg 'removing_install_dir')"
@@ -772,8 +807,10 @@ main() {
create_user create_user
setup_directories setup_directories
install_service install_service
setup_sudoers
prepare_for_setup prepare_for_setup
get_public_ip
start_service
enable_autostart
print_completion print_completion
} }

View File

@@ -1,13 +0,0 @@
# Sudoers configuration for Sub2API
# This file allows the sub2api service user to restart the service without password
#
# Installation:
# sudo cp sub2api-sudoers /etc/sudoers.d/sub2api
# sudo chmod 440 /etc/sudoers.d/sub2api
#
# SECURITY NOTE: This grants limited sudo access only for service management
# Allow sub2api user to restart the service without password
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl start sub2api

View File

@@ -9,4 +9,8 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
app.mount('#app')
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
router.isReady().then(() => {
app.mount('#app')
})