chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
|
||||
// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
|
||||
type SSHInstaller struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
PrivateKey string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
|
||||
if i.Port == 0 {
|
||||
i.Port = 22
|
||||
}
|
||||
var auth ssh.AuthMethod
|
||||
if i.PrivateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(i.PrivateKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 SSH 私钥失败: %w", err)
|
||||
}
|
||||
auth = ssh.PublicKeys(signer)
|
||||
} else if i.Password != "" {
|
||||
auth = ssh.Password(i.Password)
|
||||
} else {
|
||||
return nil, fmt.Errorf("SSH 密码或私钥不能为空")
|
||||
}
|
||||
config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: i.Timeout}
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 15 * time.Second
|
||||
}
|
||||
return ssh.Dial("tcp", fmt.Sprintf("%s:%d", i.Host, i.Port), config)
|
||||
}
|
||||
|
||||
func (i SSHInstaller) Run(ctx context.Context, command string) (string, error) {
|
||||
client, err := i.client(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
var output strings.Builder
|
||||
session.Stdout = &output
|
||||
session.Stderr = &output
|
||||
if err := session.Run(command); err != nil {
|
||||
return output.String(), fmt.Errorf("远程命令失败: %w: %s", err, strings.TrimSpace(output.String()))
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
// Install 只接受本地已构建的 agent 二进制与安装脚本,不接受用户任意命令。
|
||||
func (i SSHInstaller) Install(ctx context.Context, binary []byte, script []byte) (string, error) {
|
||||
client, err := i.client(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
if err := uploadBytes(client, "/tmp/fonu-agent", binary, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := uploadBytes(client, "/tmp/fonu-agent-install.sh", script, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runSSHSession(client, "sudo /tmp/fonu-agent-install.sh /tmp/fonu-agent && test -x /usr/local/bin/fonu-agent && /usr/local/bin/fonu-agent -version && rm -f /tmp/fonu-agent /tmp/fonu-agent-install.sh")
|
||||
}
|
||||
|
||||
func uploadBytes(client *ssh.Client, path string, data []byte, mode uint32) error {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command := fmt.Sprintf("umask 077; cat > %s; chmod %o %s", shellPath(path), mode, shellPath(path))
|
||||
if err := session.Start(command); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = stdin.Close()
|
||||
return session.Wait()
|
||||
}
|
||||
|
||||
func runSSHSession(client *ssh.Client, command string) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
var output strings.Builder
|
||||
session.Stdout = &output
|
||||
session.Stderr = &output
|
||||
if err := session.Run(command); err != nil {
|
||||
return output.String(), fmt.Errorf("远程 agent 安装失败: %w: %s", err, strings.TrimSpace(output.String()))
|
||||
}
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func shellPath(path string) string {
|
||||
return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
var _ io.Reader = (*os.File)(nil)
|
||||
var _ = filepath.Separator
|
||||
Reference in New Issue
Block a user