Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"

remotefsv1 "flyingEirc/Rclaude/api/proto/remotefs/v1"
"flyingEirc/Rclaude/pkg/auth"
Expand Down Expand Up @@ -166,7 +167,20 @@ func newGRPCServer(
return nil, nil, fmt.Errorf("server: listen %q: %w", cfg.Listen, err)
}
// recovery 拦截器置于最外层,先于 auth 执行,以兜住整条 handler 同步栈的 panic。
// keepalive:服务端主动 PING 对端,死连接在 Time+Timeout 内触发 stream 报错,
// 走既有 shutdown/UnregisterPTY 清理路径,避免 activePTY 泄漏卡死重连。
// Caddy 前置时对端是回源连接(通常 loopback),探测客户端主要靠明文直连模式。
// EnforcementPolicy 必须放宽到 MinTime < 客户端 Time(gRPC 默认 5 分钟会把
// 30s 一次的客户端 PING 判为滥用并 GOAWAY),配对约束见 pkg/config。
grpcServer := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: config.DefaultGRPCKeepaliveTime,
Timeout: config.DefaultGRPCKeepaliveTimeout,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: config.DefaultGRPCKeepaliveMinTime,
PermitWithoutStream: true,
}),
grpc.ChainStreamInterceptor(
recoveryStreamInterceptor(logger),
auth.StreamServerInterceptor(verifier),
Expand Down
4 changes: 4 additions & 0 deletions deploy/tls/Caddyfile.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
# 回源,gRPC 直接失败。
# - 不要给这条流设 response_header_timeout 或读写超时:RemotePTY/RemoteFS 都是
# 常驻双向流,交互式 PTY 可长时间空闲,任何流级超时都会把它掐断。
# - 客户端/服务端已启用 gRPC keepalive(HTTP/2 PING,见 docs/reference/
# grpc-keepalive.md)。PING 逐跳终止:客户端 PING 到 Caddy 即被应答,作用是
# 保活 client↔Caddy 段的 NAT/防火墙映射并探测该段死亡;Caddy 本身无需也不应
# 为此增加任何配置。

{$RCLAUDE_DOMAIN} {
reverse_proxy h2c://{$RCLAUDE_UPSTREAM} {
Expand Down
12 changes: 12 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ const (
DefaultAuditQueueSize = 256
DefaultStartupMaxRetries = 3
DefaultStartupRetryDelay = time.Second
// gRPC keepalive:客户端与服务端周期性发 HTTP/2 PING,一是保活路径上的
// NAT/防火墙映射(PTY 可长时间空闲,实测 40~60 分钟会被中间设备掐断),
// 二是让两端在 Time+Timeout 内探测到死连接并走既有清理路径。
// PING 逐跳终止:Caddy 前置 TLS 时,客户端 PING 只到 Caddy,服务端 PING
// 只到 Caddy 回源连接;仅明文直连时两端互相探测。详见
// docs/reference/grpc-keepalive.md。
DefaultGRPCKeepaliveTime = 30 * time.Second
DefaultGRPCKeepaliveTimeout = 10 * time.Second
// DefaultGRPCKeepaliveMinTime 是服务端 EnforcementPolicy 允许的客户端
// PING 最小间隔,必须小于 DefaultGRPCKeepaliveTime:gRPC 默认值为 5 分钟,
// 不放宽的话明文直连部署会把 30s 一次的客户端 PING 判为滥用并 GOAWAY。
DefaultGRPCKeepaliveMinTime = 10 * time.Second
)

var (
Expand Down
9 changes: 9 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,3 +557,12 @@ func escapeYAML(p string) string {
out = append(out, '"')
return string(out)
}

func TestGRPCKeepaliveDefaultsPairing(t *testing.T) {
// 服务端 EnforcementPolicy.MinTime 必须小于客户端 PING 间隔,否则明文直连
// 部署下服务端会把客户端 keepalive 判为滥用并 GOAWAY。
assert.Less(t, config.DefaultGRPCKeepaliveMinTime, config.DefaultGRPCKeepaliveTime)
// grpc-go 会把小于 10s 的客户端 keepalive Time 强制钳到 10s,
// 常量低于该值会造成"配置与实际行为不一致"。
assert.GreaterOrEqual(t, config.DefaultGRPCKeepaliveTime, 10*time.Second)
}
32 changes: 32 additions & 0 deletions pkg/ptyattach/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ import (

const defaultTerm = "xterm-256color"

// terminalResetSequence 撤销远端程序可能透传给本地终端仿真器、而断开时来不及
// 自行恢复的模式。透传架构下本地不解析字节流(区别于 mosh 的终端仿真方案,
// 见 docs/reference/mosh.md),只能在会话结束时保守复位;不支持某序列的终端
// 会按未知 CSI 忽略。实测残留案例:kitty keyboard protocol 未弹栈导致每次按键
// 回显 "9;1:3u" 之类的 release 事件残片。
const terminalResetSequence = "\x1b[<u\x1b[<u\x1b[<u" + // kitty 键盘协议:弹栈(多推少弹为空操作)
"\x1b[=0;1u" + // kitty 键盘协议:当前层标志清零兜底
"\x1b[>4;0m" + // xterm modifyOtherKeys 关闭
"\x1b[?1049l" + // 退出 alternate screen
"\x1b[?2004l" + // bracketed paste 关闭
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + // 鼠标上报及 SGR 编码关闭
"\x1b[?1004l" + // focus 上报关闭
"\x1b[?1l\x1b>" + // 光标键/小键盘回到普通模式
"\x1b[?7h" + // 自动换行恢复
"\x1b[0m" + // SGR 属性清零
"\x1b[0 q" + // 光标形状恢复默认
"\x1b[?25h" // 光标恢复可见

var (
errTTYRequired = errors.New("ptyattach: stdin and stdout must both be interactive terminals")
errEmptyServerToken = errors.New("ptyattach: daemon config must include server.token")
Expand Down Expand Up @@ -93,6 +111,7 @@ type commandRuntime struct {
stream ptyclient.Stream
closer io.Closer
stdin io.ReadCloser
stdout io.Writer
stopBridge func()
frameMax int
predictor ptyclient.Predictor
Expand Down Expand Up @@ -184,6 +203,7 @@ func prepareCommandRuntime(ctx context.Context, deps commandDeps, cfg loadedConf
stream: stream,
closer: closer,
stdin: stdin,
stdout: deps.stdout,
stopBridge: stopBridge,
frameMax: clientFrameMax(int64(cfg.FrameMax)),
predictor: newPredictor(cfg.Predict, deps.stdout, termSession.InitialSize),
Expand Down Expand Up @@ -218,11 +238,23 @@ func closeCommandRuntime(runErr *error, runtime commandRuntime) {
*runErr = closeErr
}
}
// 会话建立后的所有结束路径(含远端异常断开)都要复位终端仿真器,
// 写失败只能放弃(终端多半已不可写),仍继续恢复 termios。
writeTerminalReset(runtime.stdout)
if restoreErr := runtime.termSession.Restore(); restoreErr != nil && runErr != nil && *runErr == nil {
*runErr = fmt.Errorf("ptyattach: restore terminal: %w", restoreErr)
}
}

func writeTerminalReset(out io.Writer) {
if out == nil {
return
}
if _, err := io.WriteString(out, terminalResetSequence); err != nil {
return
}
}

func commandTermName(termName string) string {
termName = strings.TrimSpace(termName)
if termName == "" {
Expand Down
39 changes: 39 additions & 0 deletions pkg/ptyattach/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ptyattach
import (
"bytes"
"context"
"errors"
"io"
"os"
"runtime"
Expand Down Expand Up @@ -182,6 +183,8 @@ func TestRunCommandReusesDaemonConfigAndBridgesPTY(t *testing.T) {
assert.Equal(t, "example.com:9326", gotAddress)
assert.Equal(t, "tok-auth", gotToken)
assert.Contains(t, stdout.String(), "remote-ready\n")
// 会话结束时必须以终端复位序列收尾,撤销远端透传的终端模式残留。
assert.True(t, strings.HasSuffix(stdout.String(), terminalResetSequence))
assert.True(t, restoreCalled)
assert.False(t, stdin.closed)

Expand All @@ -194,6 +197,42 @@ func TestRunCommandReusesDaemonConfigAndBridgesPTY(t *testing.T) {
assert.Contains(t, sentStdinPayloads(frames), []byte("hello from cli"))
}

func TestRunCommandDialFailureSkipsTerminalReset(t *testing.T) {
var stdout bytes.Buffer
restoreCalled := false

deps := commandDeps{
stdin: io.NopCloser(bytes.NewReader(nil)),
stdout: &stdout,
loadConfig: func(string) (loadedConfig, error) {
return loadedConfig{Address: "example.com:9326", Token: "tok-auth", FrameMax: 64}, nil
},
terminal: fakeTerminal{
tty: true,
session: terminalSession{
InitialSize: ptyclient.WindowSize{Cols: 80, Rows: 24},
Resizes: closedResizeCh(),
Restore: func() error {
restoreCalled = true
return nil
},
},
},
dialPTY: func(context.Context, dialConfig) (ptyclient.Stream, io.Closer, error) {
return nil, nil, errors.New("dial failed")
},
stdinFD: 0,
stdoutFD: 1,
termName: "xterm-256color",
}

err := runCommand(context.Background(), deps, "daemon.yaml")
require.Error(t, err)
assert.True(t, restoreCalled)
// 会话从未建立,远端不可能污染终端,不应写复位序列。
assert.Empty(t, stdout.String())
}

func TestRunCommandMapsServerErrorToExitStatus(t *testing.T) {
stream := newFakeStream()

Expand Down
11 changes: 11 additions & 0 deletions pkg/transport/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"

remotefsv1 "flyingEirc/Rclaude/api/proto/remotefs/v1"
"flyingEirc/Rclaude/pkg/auth"
"flyingEirc/Rclaude/pkg/config"
)

// 错误集合:调用方应使用 errors.Is 比较。
Expand Down Expand Up @@ -63,8 +65,17 @@ func Dial(_ context.Context, opts DialOptions) (*grpc.ClientConn, error) {
if err != nil {
return nil, err
}
// keepalive 让空闲的 daemon/PTY 长连接持续产生链路流量:既防止 NAT/防火墙
// 回收空闲映射,也在 Time+Timeout 内探测到死路径让 RPC 尽快报错退出。
// Caddy 前置 TLS 时 PING 终止于 Caddy,保活与探测覆盖的是 client↔Caddy 段,
// 这正是空闲掐断的发生段;服务端对应参数见 app/server/main.go。
dialOpts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: config.DefaultGRPCKeepaliveTime,
Timeout: config.DefaultGRPCKeepaliveTimeout,
PermitWithoutStream: true,
}),
}
if opts.Dialer != nil {
dialOpts = append(dialOpts, grpc.WithContextDialer(opts.Dialer))
Expand Down
Loading