diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a43379..cb7b947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to this project will be documented in this file. - FRP 公网反代:部署预检——未安装 Nginx 时直接提示「VPS 未安装 Nginx,请先安装(apt install nginx / yum install nginx)」,替代 `nginx config rejected` 技术堆栈 - FRP:agent 部署接口由 10 个位置参数改为 `RouteSpec` 结构体(`SyncRouteSpec`),`SyncRouteEx` 保留为兼容包装 - FRP:agent 版本递增至 0.9.1,状态新增 `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` +- FRP:agent 0.9.2——Basic Auth 哈希改为**纯 Go apr1**(与 `openssl passwd -apr1` 输出逐字节一致,已用参考向量做单元测试),安装脚本 token 改用 `/dev/urandom`:agent 与安装流程彻底去除对 VPS 上 openssl 的依赖(原先缺 openssl 时会静默写入空 token、导致 Fonu 无法认证 agent) ### 修复 diff --git a/internal/agent/apr1.go b/internal/agent/apr1.go new file mode 100644 index 0000000..c8887ba --- /dev/null +++ b/internal/agent/apr1.go @@ -0,0 +1,101 @@ +package agent + +import ( + "crypto/md5" + "crypto/rand" + "fmt" + "strings" +) + +// apr1Itoa64 是 apr1(Apache MD5 crypt)使用的自定义 base64 字母表,同时用作随机盐字符集。 +const apr1Itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// apr1Crypt 生成 apr1 哈希,与 `openssl passwd -apr1` 等价(纯 Go,无外部命令依赖)。 +// 选 apr1 而非 bcrypt:Nginx 对 $apr1$ 是自带实现,跨发行版稳定;bcrypt 依赖系统 crypt(3), +// 在 CentOS 7 这类老 glibc 上无法校验。 +func apr1Crypt(password string) (string, error) { + salt, err := apr1RandomSalt(8) + if err != nil { + return "", err + } + return apr1CryptWithSalt(password, salt), nil +} + +// apr1RandomSalt 生成指定长度的随机盐(字符取自 itoa64,与 openssl 行为一致)。 +func apr1RandomSalt(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("生成随机盐失败: %w", err) + } + var b strings.Builder + for _, v := range buf { + b.WriteByte(apr1Itoa64[int(v)%len(apr1Itoa64)]) + } + return b.String(), nil +} + +// apr1CryptWithSalt 是 apr1 的确定性实现(salt 取前 8 字符),供单元测试用参考向量比对。 +// 算法来自 Apache apr_md5_encode:MD5 三轮 + 1000 次迭代 + 自定义字节序 base64。 +func apr1CryptWithSalt(password, salt string) string { + if len(salt) > 8 { + salt = salt[:8] + } + ctx := md5.New() + ctx.Write([]byte(password)) + ctx.Write([]byte("$apr1$")) + ctx.Write([]byte(salt)) + + alt := md5.Sum([]byte(password + salt + password)) + for i := len(password); i > 0; i -= 16 { + n := i + if n > 16 { + n = 16 + } + ctx.Write(alt[:n]) + } + for i := len(password); i > 0; i >>= 1 { + if i&1 != 0 { + ctx.Write([]byte{0}) + } else { + ctx.Write([]byte(password[0:1])) + } + } + final := ctx.Sum(nil) + + for i := 0; i < 1000; i++ { + round := md5.New() + if i&1 != 0 { + round.Write([]byte(password)) + } else { + round.Write(final) + } + if i%3 != 0 { + round.Write([]byte(salt)) + } + if i%7 != 0 { + round.Write([]byte(password)) + } + if i&1 != 0 { + round.Write(final) + } else { + round.Write([]byte(password)) + } + final = round.Sum(nil) + } + + var out strings.Builder + to64 := func(v uint32, n int) { + for ; n > 0; n-- { + out.WriteByte(apr1Itoa64[v&0x3f]) + v >>= 6 + } + } + to64(uint32(final[0])<<16|uint32(final[6])<<8|uint32(final[12]), 4) + to64(uint32(final[1])<<16|uint32(final[7])<<8|uint32(final[13]), 4) + to64(uint32(final[2])<<16|uint32(final[8])<<8|uint32(final[14]), 4) + to64(uint32(final[3])<<16|uint32(final[9])<<8|uint32(final[15]), 4) + to64(uint32(final[4])<<16|uint32(final[10])<<8|uint32(final[5]), 4) + to64(uint32(final[11]), 2) + + return "$apr1$" + salt + "$" + out.String() +} diff --git a/internal/agent/apr1_test.go b/internal/agent/apr1_test.go new file mode 100644 index 0000000..ffee837 --- /dev/null +++ b/internal/agent/apr1_test.go @@ -0,0 +1,58 @@ +package agent + +import "testing" + +// 参考向量由本机 OpenSSL 3.5.4 生成: +// +// openssl passwd -apr1 -salt +// +// 自研哈希必须与 openssl 完全一致,否则 Nginx Basic Auth 会永远认证失败。 +func TestApr1CryptMatchesOpenSSLVectors(t *testing.T) { + cases := []struct { + password string + salt string + want string + }{ + {"password", "xxxxxxxx", "$apr1$xxxxxxxx$dxHfLAsjHkDRmG83UXe8K0"}, + {"hello", "01234567", "$apr1$01234567$JGzOENmOOkvGYFfJQyG9X/"}, + {"P@ssw0rd!", "abcdefgh", "$apr1$abcdefgh$T9Ta7XuM2Yltpi/4uviFw0"}, + } + for _, c := range cases { + if got := apr1CryptWithSalt(c.password, c.salt); got != c.want { + t.Errorf("apr1CryptWithSalt(%q, %q)\n got: %s\nwant: %s", c.password, c.salt, got, c.want) + } + } +} + +func TestApr1CryptSaltHandling(t *testing.T) { + // 超过 8 字符的盐只取前 8 位(与 openssl / apr 行为一致) + long := apr1CryptWithSalt("password", "xxxxxxxxyyyy") + short := apr1CryptWithSalt("password", "xxxxxxxx") + if long != short { + t.Errorf("盐应截断到 8 字符: %s vs %s", long, short) + } +} + +func TestApr1CryptRandomSalt(t *testing.T) { + first, err := apr1Crypt("password") + if err != nil { + t.Fatalf("apr1Crypt 失败: %v", err) + } + second, err := apr1Crypt("password") + if err != nil { + t.Fatalf("apr1Crypt 失败: %v", err) + } + if first == second { + t.Error("随机盐应产生不同哈希") + } + // 结构:$apr1$<8 位盐>$<22 位编码> = 37 字符 + if len(first) != 37 { + t.Errorf("哈希长度应为 37,实际 %d: %s", len(first), first) + } + if first[:6] != "$apr1$" || first[14] != '$' { + t.Errorf("哈希结构异常: %s", first) + } + if same := apr1CryptWithSalt("password", first[6:14]); same != first { + t.Errorf("从哈希中取出的盐应能复现同一哈希: %s vs %s", same, first) + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go index a666dec..97f3979 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -30,7 +30,8 @@ import ( // 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。 // 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。 // 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。 -const AgentVersion = "0.9.1" +// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。 +const AgentVersion = "0.9.2" // Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -1373,11 +1374,11 @@ func sslDirectives(certDir string, tls13Only bool) string { // writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。 func writeBasicAuthSecret(domain, user, password string) error { - out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput() + hash, err := apr1Crypt(password) if err != nil { - return fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("生成 htpasswd 失败: %w", err) } - return atomicWrite(basicAuthFilePath(domain), []byte(user+":"+strings.TrimSpace(string(out))+"\n"), 0o640) + return atomicWrite(basicAuthFilePath(domain), []byte(user+":"+hash+"\n"), 0o640) } // basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。 @@ -1396,12 +1397,11 @@ func ensureBasicAuthSecret(domain, user string) (string, error) { } } password := randomToken()[:16] - // 用 openssl 生成 apr1 哈希(Nginx 原生支持);系统无 openssl 时退化为 crypt 失败报错 - out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput() + // 纯 Go 生成 apr1 哈希(等价 openssl passwd -apr1,不依赖 VPS 上的外部命令) + hash, err := apr1Crypt(password) if err != nil { - return "", fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("生成 htpasswd 失败: %w", err) } - hash := strings.TrimSpace(string(out)) if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return "", err } diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go index 28cecf9..fd50405 100644 --- a/internal/frp/agent_installer.go +++ b/internal/frp/agent_installer.go @@ -139,7 +139,8 @@ fi printf 'agent version: %s\n' "$AGENT_VERSION" if [ ! -f "$CONF_FILE" ]; then umask 077 - printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE" + # 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(缺失时会静默写入空 token,导致 Fonu 无法认证 agent) + printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE" fi cat > "$UNIT" <<'UNIT' [Unit] diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index 7c51a7a..5f7424d 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index dc48497..d63fbab 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/scripts/agent-install.sh b/scripts/agent-install.sh index 4f64f9b..b719a67 100644 --- a/scripts/agent-install.sh +++ b/scripts/agent-install.sh @@ -33,7 +33,8 @@ printf 'agent version: %s\n' "$AGENT_VERSION" if [ ! -f "$CONF_FILE" ]; then umask 077 - printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE" + # 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(与 Fonu 内嵌副本保持同一份逻辑) + printf 'FONU_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE" fi cat > "$UNIT" <<'UNIT'