feat(agent): Basic Auth 改为纯 Go apr1 并去掉 VPS openssl 依赖
- 新增 internal/agent/apr1.go(Apache apr_md5_encode 移植)+ 参考向量单元测试(与 openssl 输出逐字节一致) - ensureBasicAuthSecret / writeBasicAuthSecret 不再调用 openssl passwd -apr1 - 安装脚本 token 改用 /dev/urandom(内嵌副本与 scripts/agent-install.sh 同步) - AgentVersion 0.9.2;重编 amd64/arm64 内嵌二进制 - CHANGELOG v1.2.0 补记该改动
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// 参考向量由本机 OpenSSL 3.5.4 生成:
|
||||
//
|
||||
// openssl passwd -apr1 -salt <salt> <password>
|
||||
//
|
||||
// 自研哈希必须与 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user