gssh-agent 1.0.4 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/publish.yml +104 -0
- package/bin/gssh +0 -0
- package/cmd/gssh/main.go +2 -0
- package/internal/portforward/forwarder.go +41 -14
- package/internal/protocol/types.go +3 -1
- package/internal/session/manager.go +28 -3
- package/package.json +2 -3
- package/pkg/rpc/handler.go +1 -1
- package/skill.md +6 -0
- package/bin/daemon +0 -0
- package/bin/gssh-daemon +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- '**'
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Go
|
|
19
|
+
uses: actions/setup-go@v5
|
|
20
|
+
with:
|
|
21
|
+
go-version: '1.21'
|
|
22
|
+
|
|
23
|
+
- name: Build
|
|
24
|
+
run: go build ./...
|
|
25
|
+
|
|
26
|
+
- name: Test
|
|
27
|
+
run: go test ./...
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
# npm 发布
|
|
10
|
+
publish-npm:
|
|
11
|
+
if: "contains(github.event.head_commit.message, 'publish')"
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Go
|
|
20
|
+
uses: actions/setup-go@v5
|
|
21
|
+
with:
|
|
22
|
+
go-version: '1.21'
|
|
23
|
+
|
|
24
|
+
- name: Get version
|
|
25
|
+
id: version
|
|
26
|
+
run: |
|
|
27
|
+
VERSION=$(echo "${{ github.event.head_commit.message }}" | sed -n 's/.*publish[: ]*\(.*\)/\1/p')
|
|
28
|
+
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
|
29
|
+
echo "Publishing version: $VERSION"
|
|
30
|
+
|
|
31
|
+
- name: Build
|
|
32
|
+
run: |
|
|
33
|
+
go build -ldflags="-s -w" -o bin/gssh ./cmd/gssh
|
|
34
|
+
|
|
35
|
+
- name: Update package.json version
|
|
36
|
+
run: |
|
|
37
|
+
VERSION="${{ steps.version.outputs.VERSION }}"
|
|
38
|
+
npm version $VERSION --no-git-tag-version
|
|
39
|
+
|
|
40
|
+
- name: Build npm package
|
|
41
|
+
run: npm pack
|
|
42
|
+
|
|
43
|
+
- name: Setup Node
|
|
44
|
+
uses: actions/setup-node@v4
|
|
45
|
+
with:
|
|
46
|
+
node-version: '20'
|
|
47
|
+
|
|
48
|
+
- name: Configure npm
|
|
49
|
+
run: |
|
|
50
|
+
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
|
|
51
|
+
cat .npmrc
|
|
52
|
+
|
|
53
|
+
- name: Publish to npm
|
|
54
|
+
run: npm publish --access public
|
|
55
|
+
|
|
56
|
+
# GitHub Releases 发布
|
|
57
|
+
publish-github:
|
|
58
|
+
if: "startsWith(github.ref, 'refs/tags/v')"
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
permissions:
|
|
61
|
+
contents: write
|
|
62
|
+
|
|
63
|
+
strategy:
|
|
64
|
+
matrix:
|
|
65
|
+
include:
|
|
66
|
+
- goos: linux
|
|
67
|
+
goarch: amd64
|
|
68
|
+
- goos: linux
|
|
69
|
+
goarch: arm64
|
|
70
|
+
- goos: darwin
|
|
71
|
+
goarch: amd64
|
|
72
|
+
- goos: darwin
|
|
73
|
+
goarch: arm64
|
|
74
|
+
- goos: windows
|
|
75
|
+
goarch: amd64
|
|
76
|
+
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
|
|
80
|
+
- name: Set up Go
|
|
81
|
+
uses: actions/setup-go@v5
|
|
82
|
+
with:
|
|
83
|
+
go-version: '1.21'
|
|
84
|
+
|
|
85
|
+
- name: Build
|
|
86
|
+
env:
|
|
87
|
+
GOOS: ${{ matrix.goos }}
|
|
88
|
+
GOARCH: ${{ matrix.goarch }}
|
|
89
|
+
run: |
|
|
90
|
+
EXT=""
|
|
91
|
+
if [ "$GOOS" = "windows" ]; then
|
|
92
|
+
EXT=".exe"
|
|
93
|
+
fi
|
|
94
|
+
FILENAME="gssh-${GOOS}-${GOARCH}${EXT}"
|
|
95
|
+
go build -ldflags="-s -w" -o "$FILENAME" ./cmd/gssh
|
|
96
|
+
echo "$FILENAME" >> artifacts.txt
|
|
97
|
+
|
|
98
|
+
- name: Upload Release Asset
|
|
99
|
+
uses: softprops/action-gh-release@v2
|
|
100
|
+
with:
|
|
101
|
+
tag_name: ${{ github.ref }}
|
|
102
|
+
files: ${{ matrix.goos }}-${{ matrix.goarch }}*
|
|
103
|
+
env:
|
|
104
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/bin/gssh
CHANGED
|
Binary file
|
package/cmd/gssh/main.go
CHANGED
|
@@ -309,6 +309,7 @@ func handleExec(args []string, socketPath string) error {
|
|
|
309
309
|
fs.SetOutput(os.Stderr)
|
|
310
310
|
|
|
311
311
|
sessionID := fs.String("s", "", "Session ID")
|
|
312
|
+
timeout := fs.Int("t", 0, "Timeout in seconds (0 = no timeout)")
|
|
312
313
|
sudoPassword := fs.String("S", "", "sudo password")
|
|
313
314
|
askSudoPassword := fs.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
|
|
314
315
|
|
|
@@ -334,6 +335,7 @@ func handleExec(args []string, socketPath string) error {
|
|
|
334
335
|
params := protocol.ExecParams{
|
|
335
336
|
SessionID: *sessionID,
|
|
336
337
|
Command: command,
|
|
338
|
+
Timeout: *timeout,
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
result, err := sendRequest(socketPath, "exec", params)
|
|
@@ -6,6 +6,7 @@ import (
|
|
|
6
6
|
"log"
|
|
7
7
|
"net"
|
|
8
8
|
"sync"
|
|
9
|
+
"time"
|
|
9
10
|
|
|
10
11
|
"golang.org/x/crypto/ssh"
|
|
11
12
|
)
|
|
@@ -74,14 +75,22 @@ func (f *Forwarder) startLocalForward() {
|
|
|
74
75
|
}
|
|
75
76
|
f.mu.RUnlock()
|
|
76
77
|
|
|
78
|
+
// Set deadline to prevent blocking forever
|
|
79
|
+
f.listener.(*net.TCPListener).SetDeadline(time.Now().Add(5 * time.Second))
|
|
80
|
+
|
|
77
81
|
conn, err := f.listener.Accept()
|
|
78
82
|
if err != nil {
|
|
83
|
+
// Check if it's a timeout
|
|
84
|
+
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
79
87
|
f.mu.RLock()
|
|
80
88
|
closed := f.closed
|
|
81
89
|
f.mu.RUnlock()
|
|
82
90
|
if closed {
|
|
83
91
|
return
|
|
84
92
|
}
|
|
93
|
+
log.Printf("[portforward] Accept error: %v", err)
|
|
85
94
|
continue
|
|
86
95
|
}
|
|
87
96
|
|
|
@@ -105,8 +114,10 @@ func (f *Forwarder) handleLocalConnection(localConn net.Conn) {
|
|
|
105
114
|
|
|
106
115
|
log.Printf("[portforward] Connection accepted from %s", localConn.RemoteAddr())
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
// Use 127.0.0.1 instead of localhost to avoid IPv6 issues
|
|
118
|
+
remoteAddr := fmt.Sprintf("127.0.0.1:%d", f.RemotePort)
|
|
119
|
+
|
|
120
|
+
remoteConn, err := net.DialTimeout("tcp", remoteAddr, 5*time.Second)
|
|
110
121
|
if err != nil {
|
|
111
122
|
log.Printf("[portforward] Failed to connect to remote %s: %v", remoteAddr, err)
|
|
112
123
|
return
|
|
@@ -136,27 +147,41 @@ func (f *Forwarder) startRemoteForward() {
|
|
|
136
147
|
defer f.wg.Done()
|
|
137
148
|
|
|
138
149
|
// Request the SSH server to listen on remote:remotePort and forward connections to us
|
|
139
|
-
// Payload format: string (address) + uint32 (port)
|
|
140
150
|
addr := fmt.Sprintf(":%d", f.RemotePort)
|
|
141
151
|
payload := ssh.Marshal(struct {
|
|
142
152
|
Addr string
|
|
143
153
|
Port uint32
|
|
144
154
|
}{Addr: addr, Port: uint32(f.RemotePort)})
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
// Use timeout for the request
|
|
157
|
+
type result struct {
|
|
158
|
+
ok bool
|
|
159
|
+
err error
|
|
150
160
|
}
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
resultCh := make(chan result, 1)
|
|
162
|
+
go func() {
|
|
163
|
+
ok, _, err := f.sshClient.SendRequest("tcpip-forward", true, payload)
|
|
164
|
+
resultCh <- result{ok: ok, err: err}
|
|
165
|
+
}()
|
|
166
|
+
|
|
167
|
+
select {
|
|
168
|
+
case r := <-resultCh:
|
|
169
|
+
if r.err != nil {
|
|
170
|
+
log.Printf("[portforward] Failed to send tcpip-forward request: %v", r.err)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if !r.ok {
|
|
174
|
+
log.Printf("[portforward] SSH server rejected tcpip-forward request for port %d", f.RemotePort)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
case <-time.After(5 * time.Second):
|
|
178
|
+
log.Printf("[portforward] tcpip-forward request timed out")
|
|
153
179
|
return
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
log.Printf("[portforward] Remote forward: SSH server listening on port %d", f.RemotePort)
|
|
157
183
|
|
|
158
|
-
//
|
|
159
|
-
// The SSH server will open a "forwarded-tcpip" channel for each connection
|
|
184
|
+
// Accept forwarded connections in a loop
|
|
160
185
|
for {
|
|
161
186
|
f.mu.RLock()
|
|
162
187
|
if f.closed {
|
|
@@ -165,7 +190,7 @@ func (f *Forwarder) startRemoteForward() {
|
|
|
165
190
|
}
|
|
166
191
|
f.mu.RUnlock()
|
|
167
192
|
|
|
168
|
-
// Wait for a forwarded connection
|
|
193
|
+
// Wait for a forwarded connection with timeout
|
|
169
194
|
ch, reqs, err := f.sshClient.OpenChannel("forwarded-tcpip", nil)
|
|
170
195
|
if err != nil {
|
|
171
196
|
f.mu.RLock()
|
|
@@ -174,7 +199,9 @@ func (f *Forwarder) startRemoteForward() {
|
|
|
174
199
|
if closed {
|
|
175
200
|
return
|
|
176
201
|
}
|
|
202
|
+
// Log and continue - don't block
|
|
177
203
|
log.Printf("[portforward] Error accepting forwarded connection: %v", err)
|
|
204
|
+
time.Sleep(1 * time.Second)
|
|
178
205
|
continue
|
|
179
206
|
}
|
|
180
207
|
|
|
@@ -182,8 +209,8 @@ func (f *Forwarder) startRemoteForward() {
|
|
|
182
209
|
go ssh.DiscardRequests(reqs)
|
|
183
210
|
|
|
184
211
|
// Connect to local port
|
|
185
|
-
localAddr := fmt.Sprintf("
|
|
186
|
-
localConn, err := net.
|
|
212
|
+
localAddr := fmt.Sprintf("127.0.0.1:%d", f.LocalPort)
|
|
213
|
+
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
|
|
187
214
|
if err != nil {
|
|
188
215
|
log.Printf("[portforward] Failed to connect to local port %d: %v", f.LocalPort, err)
|
|
189
216
|
ch.Close()
|
|
@@ -72,6 +72,7 @@ type ReconnectParams struct {
|
|
|
72
72
|
type ExecParams struct {
|
|
73
73
|
SessionID string `json:"session_id,omitempty"`
|
|
74
74
|
Command string `json:"command"`
|
|
75
|
+
Timeout int `json:"timeout,omitempty"` // 超时时间(秒),0 表示无超时
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
type ForwardParams struct {
|
|
@@ -108,6 +109,7 @@ type SCPResult struct {
|
|
|
108
109
|
// SFTPParams represents SFTP command parameters
|
|
109
110
|
type SFTPParams struct {
|
|
110
111
|
SessionID string `json:"session_id,omitempty"`
|
|
111
|
-
Command string `json:"command"`
|
|
112
|
+
Command string `json:"command"`
|
|
113
|
+
Timeout int `json:"timeout,omitempty"` // 超时时间(秒),0 表示无超时 // "ls", "cd", "pwd", "mkdir", "rm", "rmdir"
|
|
112
114
|
Path string `json:"path"`
|
|
113
115
|
}
|
|
@@ -224,7 +224,7 @@ func (m *Manager) Reconnect(sessionID string) (*protocol.Session, error) {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
// Exec executes a command on a session
|
|
227
|
-
func (m *Manager) Exec(sessionID, command string) (*protocol.ExecResult, error) {
|
|
227
|
+
func (m *Manager) Exec(sessionID, command string, timeout int) (*protocol.ExecResult, error) {
|
|
228
228
|
m.mu.RLock()
|
|
229
229
|
var ms *ManagedSession
|
|
230
230
|
if sessionID != "" {
|
|
@@ -259,9 +259,34 @@ func (m *Manager) Exec(sessionID, command string) (*protocol.ExecResult, error)
|
|
|
259
259
|
fullCmd = fmt.Sprintf("/bin/sh -c %q", command)
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
|
|
262
|
+
// 执行命令,支持超时
|
|
263
|
+
var output []byte
|
|
264
|
+
var exitErr *ssh.ExitError
|
|
265
|
+
var ok bool
|
|
266
|
+
|
|
267
|
+
if timeout > 0 {
|
|
268
|
+
done := make(chan struct{})
|
|
269
|
+
go func() {
|
|
270
|
+
output, err = session.CombinedOutput(fullCmd)
|
|
271
|
+
close(done)
|
|
272
|
+
}()
|
|
273
|
+
select {
|
|
274
|
+
case <-done:
|
|
275
|
+
// 命令执行完成
|
|
276
|
+
case <-time.After(time.Duration(timeout) * time.Second):
|
|
277
|
+
session.Signal(ssh.SIGKILL)
|
|
278
|
+
return &protocol.ExecResult{
|
|
279
|
+
Stdout: string(output),
|
|
280
|
+
Stderr: "",
|
|
281
|
+
ExitCode: -1,
|
|
282
|
+
}, fmt.Errorf("command timed out after %d seconds", timeout)
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
output, err = session.CombinedOutput(fullCmd)
|
|
286
|
+
}
|
|
287
|
+
|
|
263
288
|
if err != nil {
|
|
264
|
-
exitErr, ok
|
|
289
|
+
exitErr, ok = err.(*ssh.ExitError)
|
|
265
290
|
if ok {
|
|
266
291
|
ms.LastCmd = command
|
|
267
292
|
return &protocol.ExecResult{
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gssh-agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "SSH Session Manager for Agents - Stateless SSH client with SFTP support",
|
|
5
5
|
"bin": {
|
|
6
|
-
"gssh": "./bin/gssh"
|
|
7
|
-
"gssh-daemon": "./bin/gssh-daemon"
|
|
6
|
+
"gssh": "./bin/gssh"
|
|
8
7
|
},
|
|
9
8
|
"repository": {
|
|
10
9
|
"type": "git",
|
package/pkg/rpc/handler.go
CHANGED
|
@@ -60,7 +60,7 @@ func (h *Handler) Handle(data []byte) ([]byte, error) {
|
|
|
60
60
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
61
61
|
return h.errorResponse(req.ID, -32602, "Invalid params")
|
|
62
62
|
}
|
|
63
|
-
result, err = h.manager.Exec(params.SessionID, params.Command)
|
|
63
|
+
result, err = h.manager.Exec(params.SessionID, params.Command, params.Timeout)
|
|
64
64
|
|
|
65
65
|
case "list":
|
|
66
66
|
result = h.manager.List()
|
package/skill.md
CHANGED
|
@@ -41,8 +41,14 @@ gssh exec "ls -la"
|
|
|
41
41
|
|
|
42
42
|
# sudo 命令
|
|
43
43
|
gssh exec -S password "sudo systemctl restart nginx"
|
|
44
|
+
|
|
45
|
+
# 后台运行(推荐格式)
|
|
46
|
+
gssh exec "nohup python3 app.py > /dev/null 2>&1 &"
|
|
47
|
+
gssh exec "python3 app.py &"
|
|
44
48
|
```
|
|
45
49
|
|
|
50
|
+
**后台运行说明**:推荐使用 `nohup command > /dev/null 2>&1 &` 格式,可确保命令立即返回且不阻塞。
|
|
51
|
+
|
|
46
52
|
## 文件传输
|
|
47
53
|
|
|
48
54
|
```bash
|
package/bin/daemon
DELETED
|
Binary file
|
package/bin/gssh-daemon
DELETED
|
Binary file
|