Compare commits

...

10 Commits

Author SHA1 Message Date
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
shaw
8e4bd42e8c fix: 修复安装/升级无法重启服务的问题 2025-12-18 17:44:49 +08:00
shaw
ef3199f0ca fix: 修复脚本的一些参数问题 2025-12-18 17:25:26 +08:00
10 changed files with 190 additions and 88 deletions

3
.gitignore vendored
View File

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

View File

@@ -107,7 +107,7 @@ sudo journalctl -u sub2api -f
sudo systemctl restart sub2api
# Uninstall
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s uninstall
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
```
---

View File

@@ -107,7 +107,7 @@ sudo journalctl -u sub2api -f
sudo systemctl restart sub2api
# 卸载
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s uninstall
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
```
---

View File

@@ -2,8 +2,10 @@ package admin
import (
"net/http"
"time"
"sub2api/internal/pkg/response"
"sub2api/internal/pkg/sysutil"
"sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -72,10 +74,14 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
// RestartService restarts the systemd service
// POST /api/v1/admin/system/restart
func (h *SystemHandler) RestartService(c *gin.Context) {
if err := h.updateSvc.RestartService(); err != nil {
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
// Schedule service restart in background after sending response
// This ensures the client receives the success response before the service restarts
go func() {
// Wait a moment to ensure the response is sent
time.Sleep(500 * time.Millisecond)
sysutil.RestartServiceAsync()
}()
response.Success(c, gin.H{
"message": "Service restart initiated",
})

View File

@@ -0,0 +1,96 @@
package sysutil
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
)
const serviceName = "sub2api"
// findExecutable finds the full path of an executable
// by checking common system paths
func findExecutable(name string) string {
// First try exec.LookPath (uses current PATH)
if path, err := exec.LookPath(name); err == nil {
return path
}
// Fallback: check common paths
commonPaths := []string{
"/usr/bin/" + name,
"/bin/" + name,
"/usr/sbin/" + name,
"/sbin/" + name,
}
for _, path := range commonPaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
// Return the name as-is and let exec fail with a clear error
return name
}
// RestartService triggers a service restart via systemd.
//
// IMPORTANT: This function initiates the restart and returns immediately.
// The actual restart happens asynchronously - the current process will be killed
// by systemd and a new process will be started.
//
// We use Start() instead of Run() because:
// - systemctl restart will kill the current process first
// - Run() waits for completion, but the process dies before completion
// - Start() spawns the command independently, allowing systemd to handle the full cycle
//
// Prerequisites:
// - Linux OS with systemd
// - NOPASSWD sudo access configured (install.sh creates /etc/sudoers.d/sub2api)
func RestartService() error {
if runtime.GOOS != "linux" {
return fmt.Errorf("systemd restart only available on Linux")
}
log.Println("Initiating service restart...")
// Find full paths for sudo and systemctl
// This ensures the commands work even if PATH is limited in systemd service
sudoPath := findExecutable("sudo")
systemctlPath := findExecutable("systemctl")
log.Printf("Using sudo: %s, systemctl: %s", sudoPath, systemctlPath)
// The sub2api user has NOPASSWD sudo access for systemctl commands
// (configured by install.sh in /etc/sudoers.d/sub2api).
// Use -n (non-interactive) to prevent sudo from waiting for password input
//
// Use setsid to create a new session, ensuring the child process
// survives even if the parent process is killed by systemctl restart
setsidPath := findExecutable("setsid")
cmd := exec.Command(setsidPath, sudoPath, "-n", systemctlPath, "restart", serviceName)
// Detach from parent's stdio to ensure clean separation
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to initiate service restart: %w", err)
}
log.Println("Service restart initiated successfully")
return nil
}
// RestartServiceAsync is a fire-and-forget version of RestartService.
// It logs errors instead of returning them, suitable for goroutine usage.
func RestartServiceAsync() {
if err := RestartService(); err != nil {
log.Printf("Service restart failed: %v", err)
log.Println("Please restart the service manually: sudo systemctl restart sub2api")
}
}

View File

@@ -35,25 +35,25 @@ const (
// allowedHeaders 白名单headers参考CRS项目
var allowedHeaders = map[string]bool{
"accept": true,
"x-stainless-retry-count": true,
"x-stainless-timeout": true,
"x-stainless-lang": true,
"x-stainless-package-version": true,
"x-stainless-os": true,
"x-stainless-arch": true,
"x-stainless-runtime": true,
"x-stainless-runtime-version": true,
"x-stainless-helper-method": true,
"accept": true,
"x-stainless-retry-count": true,
"x-stainless-timeout": true,
"x-stainless-lang": true,
"x-stainless-package-version": true,
"x-stainless-os": true,
"x-stainless-arch": true,
"x-stainless-runtime": true,
"x-stainless-runtime-version": true,
"x-stainless-helper-method": true,
"anthropic-dangerous-direct-browser-access": true,
"anthropic-version": true,
"x-app": true,
"anthropic-beta": true,
"accept-language": true,
"sec-fetch-mode": true,
"accept-encoding": true,
"user-agent": true,
"content-type": true,
"anthropic-version": true,
"x-app": true,
"anthropic-beta": true,
"accept-language": true,
"sec-fetch-mode": true,
"accept-encoding": true,
"user-agent": true,
"content-type": true,
}
// 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 {
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 {
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 {
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 {
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 {
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
}
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
targetURL := claudeAPIURL
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")))
}
// 配置代理
// 配置代理 - 创建独立的client避免并发修改共享httpClient
var customClient *http.Client
if account.ProxyID != nil && account.Proxy != nil {
proxyURL := account.Proxy.URL()
if proxyURL != "" {
@@ -566,12 +584,18 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
IdleConnTimeout: 90 * time.Second,
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

View File

@@ -13,7 +13,6 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -244,23 +243,6 @@ func (s *UpdateService) Rollback() error {
return nil
}
// RestartService triggers a service restart via systemd
func (s *UpdateService) RestartService() error {
if runtime.GOOS != "linux" {
return fmt.Errorf("systemd restart only available on Linux")
}
// Try direct systemctl first (works if running as root or with proper permissions)
cmd := exec.Command("systemctl", "restart", "sub2api")
if err := cmd.Run(); err != nil {
// Try with sudo (requires NOPASSWD sudoers entry)
sudoCmd := exec.Command("sudo", "systemctl", "restart", "sub2api")
if sudoErr := sudoCmd.Run(); sudoErr != nil {
return fmt.Errorf("systemctl restart failed: %w (sudo also failed: %v)", err, sudoErr)
}
}
return nil
}
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)

View File

@@ -2,17 +2,15 @@ package setup
import (
"fmt"
"log"
"net/http"
"net/mail"
"os/exec"
"regexp"
"runtime"
"strings"
"sync"
"time"
"sub2api/internal/pkg/response"
"sub2api/internal/pkg/sysutil"
"github.com/gin-gonic/gin"
)
@@ -346,7 +344,7 @@ func install(c *gin.Context) {
go func() {
// Wait a moment to ensure the response is sent
time.Sleep(500 * time.Millisecond)
triggerServiceRestart()
sysutil.RestartServiceAsync()
}()
response.Success(c, gin.H{
@@ -355,27 +353,3 @@ func install(c *gin.Context) {
})
}
// triggerServiceRestart attempts to restart the service via systemd
// This is called after setup completes to switch from setup mode to normal mode
func triggerServiceRestart() {
if runtime.GOOS != "linux" {
log.Println("Service restart: not on Linux, manual restart required")
return
}
log.Println("Setup completed, triggering service restart...")
// Try direct systemctl first (works if running as root or with proper permissions)
cmd := exec.Command("systemctl", "restart", "sub2api")
if err := cmd.Run(); err != nil {
// Try with sudo (requires NOPASSWD sudoers entry)
sudoCmd := exec.Command("sudo", "systemctl", "restart", "sub2api")
if sudoErr := sudoCmd.Run(); sudoErr != nil {
log.Printf("Service restart failed: %v (sudo also failed: %v)", err, sudoErr)
log.Println("Please restart the service manually: sudo systemctl restart sub2api")
return
}
}
log.Println("Service restart initiated successfully")
}

View File

@@ -483,9 +483,24 @@ download_and_extract() {
create_user() {
if id "$SERVICE_USER" &>/dev/null; then
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
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')"
fi
}
@@ -510,18 +525,18 @@ setup_directories() {
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'
# Always generate sudoers file from script (not from tar.gz)
# This ensures the latest configuration is used even with older releases
# Support both /bin/systemctl and /usr/bin/systemctl for different distros
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
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl start sub2api
EOF
fi
# Set correct permissions (required for sudoers files)
chmod 440 /etc/sudoers.d/sub2api
@@ -683,7 +698,7 @@ uninstall() {
# If not interactive (piped), require -y flag or skip confirmation
if ! is_interactive; then
if [ "${FORCE_YES:-}" != "true" ]; then
print_error "Non-interactive mode detected. Use 'bash -s -- uninstall -y' to confirm."
print_error "Non-interactive mode detected. Use 'curl ... | bash -s -- uninstall -y' to confirm."
exit 1
fi
else

View File

@@ -8,6 +8,10 @@
# SECURITY NOTE: This grants limited sudo access only for service management
# Allow sub2api user to restart the service without password
# Support both /bin/systemctl (Debian/Ubuntu) and /usr/bin/systemctl (RHEL/CentOS)
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl start sub2api
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /usr/bin/systemctl start sub2api