gssh-agent 1.0.3 → 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 +140 -112
- package/internal/portforward/forwarder.go +41 -14
- package/internal/protocol/types.go +3 -1
- package/internal/session/manager.go +40 -5
- 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
|
@@ -21,58 +21,62 @@ const (
|
|
|
21
21
|
version = "0.1.0"
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
var (
|
|
25
|
-
socketPath = flag.String("socket", defaultSocketPath, "Unix socket path")
|
|
26
|
-
)
|
|
27
|
-
|
|
28
24
|
func main() {
|
|
29
|
-
// Check for version flag before parsing
|
|
25
|
+
// Check for version flag before any parsing
|
|
30
26
|
if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version") {
|
|
31
27
|
fmt.Printf("gssh version %s\n", version)
|
|
32
28
|
os.Exit(0)
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
// Check for help flag
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
os.Exit(0)
|
|
40
|
-
}
|
|
31
|
+
// Check for help flag
|
|
32
|
+
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
|
|
33
|
+
printUsage()
|
|
34
|
+
os.Exit(0)
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
flag
|
|
37
|
+
// Parse global -socket flag manually
|
|
38
|
+
socketPath := defaultSocketPath
|
|
39
|
+
args := os.Args[1:]
|
|
40
|
+
for i := 0; i < len(args); i++ {
|
|
41
|
+
if args[i] == "-socket" && i+1 < len(args) {
|
|
42
|
+
socketPath = args[i+1]
|
|
43
|
+
args = append(args[:i], args[i+2:]...)
|
|
44
|
+
i--
|
|
45
|
+
}
|
|
46
|
+
}
|
|
44
47
|
|
|
45
|
-
if
|
|
48
|
+
if len(args) < 1 {
|
|
46
49
|
printUsage()
|
|
47
50
|
os.Exit(1)
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
cmd :=
|
|
53
|
+
cmd := args[0]
|
|
54
|
+
subArgs := args[1:]
|
|
51
55
|
|
|
52
56
|
var err error
|
|
53
57
|
switch cmd {
|
|
54
58
|
case "connect":
|
|
55
|
-
err = handleConnect()
|
|
59
|
+
err = handleConnect(subArgs, socketPath)
|
|
56
60
|
case "disconnect":
|
|
57
|
-
err = handleDisconnect()
|
|
61
|
+
err = handleDisconnect(subArgs, socketPath)
|
|
58
62
|
case "reconnect":
|
|
59
|
-
err = handleReconnect()
|
|
63
|
+
err = handleReconnect(subArgs, socketPath)
|
|
60
64
|
case "exec":
|
|
61
|
-
err = handleExec()
|
|
65
|
+
err = handleExec(subArgs, socketPath)
|
|
62
66
|
case "list", "ls":
|
|
63
|
-
err = handleList()
|
|
67
|
+
err = handleList(socketPath)
|
|
64
68
|
case "use":
|
|
65
|
-
err = handleUse()
|
|
69
|
+
err = handleUse(subArgs, socketPath)
|
|
66
70
|
case "forward":
|
|
67
|
-
err = handleForward()
|
|
71
|
+
err = handleForward(subArgs, socketPath)
|
|
68
72
|
case "forwards":
|
|
69
|
-
err = handleForwards()
|
|
73
|
+
err = handleForwards(socketPath)
|
|
70
74
|
case "forward-close":
|
|
71
|
-
err = handleForwardClose()
|
|
75
|
+
err = handleForwardClose(subArgs, socketPath)
|
|
72
76
|
case "scp":
|
|
73
|
-
err = handleSCP()
|
|
77
|
+
err = handleSCP(subArgs, socketPath)
|
|
74
78
|
case "sftp":
|
|
75
|
-
err = handleSFTP()
|
|
79
|
+
err = handleSFTP(subArgs, socketPath)
|
|
76
80
|
case "help":
|
|
77
81
|
printUsage()
|
|
78
82
|
default:
|
|
@@ -89,9 +93,7 @@ func main() {
|
|
|
89
93
|
|
|
90
94
|
// readPassword reads password from terminal without echo
|
|
91
95
|
func readPassword() (string, error) {
|
|
92
|
-
// Check if stdin is a terminal
|
|
93
96
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
94
|
-
// Try to read from stdin
|
|
95
97
|
reader := bufio.NewReader(os.Stdin)
|
|
96
98
|
fmt.Print("Password: ")
|
|
97
99
|
password, err := reader.ReadString('\n')
|
|
@@ -131,8 +133,8 @@ func readPassphrase() (string, error) {
|
|
|
131
133
|
return string(bytePassphrase), nil
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
func sendRequest(method string, params interface{}) ([]byte, error) {
|
|
135
|
-
conn, err := net.Dial("unix",
|
|
136
|
+
func sendRequest(socketPath, method string, params interface{}) ([]byte, error) {
|
|
137
|
+
conn, err := net.Dial("unix", socketPath)
|
|
136
138
|
if err != nil {
|
|
137
139
|
return nil, fmt.Errorf("failed to connect to daemon: %w", err)
|
|
138
140
|
}
|
|
@@ -155,7 +157,6 @@ func sendRequest(method string, params interface{}) ([]byte, error) {
|
|
|
155
157
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
156
158
|
}
|
|
157
159
|
|
|
158
|
-
// Add newline to signal end of request
|
|
159
160
|
reqData = append(reqData, '\n')
|
|
160
161
|
|
|
161
162
|
_, err = conn.Write(reqData)
|
|
@@ -163,14 +164,12 @@ func sendRequest(method string, params interface{}) ([]byte, error) {
|
|
|
163
164
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
// Read line by line
|
|
167
167
|
reader := bufio.NewReader(conn)
|
|
168
168
|
line, err := reader.ReadBytes('\n')
|
|
169
169
|
if err != nil {
|
|
170
170
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// Remove trailing newline
|
|
174
173
|
line = line[:len(line)-1]
|
|
175
174
|
|
|
176
175
|
var resp protocol.Response
|
|
@@ -190,22 +189,24 @@ func sendRequest(method string, params interface{}) ([]byte, error) {
|
|
|
190
189
|
return result, nil
|
|
191
190
|
}
|
|
192
191
|
|
|
193
|
-
func handleConnect() error {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
192
|
+
func handleConnect(args []string, socketPath string) error {
|
|
193
|
+
fs := flag.NewFlagSet("connect", flag.ContinueOnError)
|
|
194
|
+
fs.SetOutput(os.Stderr)
|
|
195
|
+
|
|
196
|
+
user := fs.String("u", "", "Username")
|
|
197
|
+
host := fs.String("h", "", "Host")
|
|
198
|
+
port := fs.Int("p", 22, "Port")
|
|
199
|
+
password := fs.String("P", "", "Password")
|
|
200
|
+
keyPath := fs.String("i", "", "SSH key path")
|
|
201
|
+
askPassword := fs.Bool("ask-pass", false, "Ask for password interactively")
|
|
202
|
+
askPassphrase := fs.Bool("ask-passphrase", false, "Ask for key passphrase interactively")
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
fs.Parse(args)
|
|
203
205
|
|
|
204
206
|
if *user == "" || *host == "" {
|
|
205
207
|
return fmt.Errorf("user and host are required")
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
// If no password provided via flag and -ask-pass is set, read interactively
|
|
209
210
|
if *password == "" && *askPassword {
|
|
210
211
|
p, err := readPassword()
|
|
211
212
|
if err != nil {
|
|
@@ -214,14 +215,11 @@ func handleConnect() error {
|
|
|
214
215
|
*password = p
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
// If key path provided and -ask-passphrase is set, read interactively
|
|
218
218
|
if *keyPath != "" && *askPassphrase {
|
|
219
219
|
passphrase, err := readPassphrase()
|
|
220
220
|
if err != nil {
|
|
221
221
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
|
222
222
|
}
|
|
223
|
-
// Note: Passphrase support would require modifying the SSH client
|
|
224
|
-
// For now, we'll just warn that it's not supported
|
|
225
223
|
if passphrase != "" {
|
|
226
224
|
fmt.Println("Note: Passphrase for keys is not yet supported, ignoring")
|
|
227
225
|
}
|
|
@@ -235,7 +233,7 @@ func handleConnect() error {
|
|
|
235
233
|
KeyPath: *keyPath,
|
|
236
234
|
}
|
|
237
235
|
|
|
238
|
-
result, err := sendRequest("connect", params)
|
|
236
|
+
result, err := sendRequest(socketPath, "connect", params)
|
|
239
237
|
if err != nil {
|
|
240
238
|
return err
|
|
241
239
|
}
|
|
@@ -249,15 +247,24 @@ func handleConnect() error {
|
|
|
249
247
|
return nil
|
|
250
248
|
}
|
|
251
249
|
|
|
252
|
-
func handleDisconnect() error {
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
func handleDisconnect(args []string, socketPath string) error {
|
|
251
|
+
fs := flag.NewFlagSet("disconnect", flag.ContinueOnError)
|
|
252
|
+
fs.SetOutput(os.Stderr)
|
|
253
|
+
|
|
254
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
255
|
+
fs.Parse(args)
|
|
256
|
+
|
|
257
|
+
// Support positional argument
|
|
258
|
+
sessionIDStr := *sessionID
|
|
259
|
+
if sessionIDStr == "" && fs.NArg() > 0 {
|
|
260
|
+
sessionIDStr = fs.Arg(0)
|
|
261
|
+
}
|
|
255
262
|
|
|
256
263
|
params := protocol.DisconnectParams{
|
|
257
|
-
SessionID:
|
|
264
|
+
SessionID: sessionIDStr,
|
|
258
265
|
}
|
|
259
266
|
|
|
260
|
-
_, err := sendRequest("disconnect", params)
|
|
267
|
+
_, err := sendRequest(socketPath, "disconnect", params)
|
|
261
268
|
if err != nil {
|
|
262
269
|
return err
|
|
263
270
|
}
|
|
@@ -266,15 +273,24 @@ func handleDisconnect() error {
|
|
|
266
273
|
return nil
|
|
267
274
|
}
|
|
268
275
|
|
|
269
|
-
func handleReconnect() error {
|
|
270
|
-
|
|
271
|
-
|
|
276
|
+
func handleReconnect(args []string, socketPath string) error {
|
|
277
|
+
fs := flag.NewFlagSet("reconnect", flag.ContinueOnError)
|
|
278
|
+
fs.SetOutput(os.Stderr)
|
|
279
|
+
|
|
280
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
281
|
+
fs.Parse(args)
|
|
282
|
+
|
|
283
|
+
// Support positional argument
|
|
284
|
+
sessionIDStr := *sessionID
|
|
285
|
+
if sessionIDStr == "" && fs.NArg() > 0 {
|
|
286
|
+
sessionIDStr = fs.Arg(0)
|
|
287
|
+
}
|
|
272
288
|
|
|
273
289
|
params := protocol.ReconnectParams{
|
|
274
|
-
SessionID:
|
|
290
|
+
SessionID: sessionIDStr,
|
|
275
291
|
}
|
|
276
292
|
|
|
277
|
-
result, err := sendRequest("reconnect", params)
|
|
293
|
+
result, err := sendRequest(socketPath, "reconnect", params)
|
|
278
294
|
if err != nil {
|
|
279
295
|
return err
|
|
280
296
|
}
|
|
@@ -288,22 +304,22 @@ func handleReconnect() error {
|
|
|
288
304
|
return nil
|
|
289
305
|
}
|
|
290
306
|
|
|
291
|
-
func handleExec() error {
|
|
292
|
-
|
|
293
|
-
|
|
307
|
+
func handleExec(args []string, socketPath string) error {
|
|
308
|
+
fs := flag.NewFlagSet("exec", flag.ContinueOnError)
|
|
309
|
+
fs.SetOutput(os.Stderr)
|
|
294
310
|
|
|
295
|
-
sessionID :=
|
|
296
|
-
|
|
297
|
-
|
|
311
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
312
|
+
timeout := fs.Int("t", 0, "Timeout in seconds (0 = no timeout)")
|
|
313
|
+
sudoPassword := fs.String("S", "", "sudo password")
|
|
314
|
+
askSudoPassword := fs.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
|
|
298
315
|
|
|
299
|
-
|
|
316
|
+
fs.Parse(args)
|
|
300
317
|
|
|
301
|
-
if
|
|
318
|
+
if fs.NArg() < 1 {
|
|
302
319
|
return fmt.Errorf("command required")
|
|
303
320
|
}
|
|
304
|
-
command := strings.Join(
|
|
321
|
+
command := strings.Join(fs.Args(), " ")
|
|
305
322
|
|
|
306
|
-
// If sudo password needed but not provided, prompt for it
|
|
307
323
|
if *askSudoPassword && *sudoPassword == "" {
|
|
308
324
|
p, err := readPassword()
|
|
309
325
|
if err != nil {
|
|
@@ -312,18 +328,17 @@ func handleExec() error {
|
|
|
312
328
|
*sudoPassword = p
|
|
313
329
|
}
|
|
314
330
|
|
|
315
|
-
// If command contains sudo and password provided, wrap the command
|
|
316
331
|
if *sudoPassword != "" && strings.Contains(command, "sudo") {
|
|
317
|
-
// Use printf to pipe password to sudo -S
|
|
318
332
|
command = fmt.Sprintf("printf '%%s\\n' '%s' | %s -S", *sudoPassword, command)
|
|
319
333
|
}
|
|
320
334
|
|
|
321
335
|
params := protocol.ExecParams{
|
|
322
336
|
SessionID: *sessionID,
|
|
323
337
|
Command: command,
|
|
338
|
+
Timeout: *timeout,
|
|
324
339
|
}
|
|
325
340
|
|
|
326
|
-
result, err := sendRequest("exec", params)
|
|
341
|
+
result, err := sendRequest(socketPath, "exec", params)
|
|
327
342
|
if err != nil {
|
|
328
343
|
return err
|
|
329
344
|
}
|
|
@@ -345,8 +360,8 @@ func handleExec() error {
|
|
|
345
360
|
return nil
|
|
346
361
|
}
|
|
347
362
|
|
|
348
|
-
func handleList() error {
|
|
349
|
-
result, err := sendRequest("list", nil)
|
|
363
|
+
func handleList(socketPath string) error {
|
|
364
|
+
result, err := sendRequest(socketPath, "list", nil)
|
|
350
365
|
if err != nil {
|
|
351
366
|
return err
|
|
352
367
|
}
|
|
@@ -369,19 +384,22 @@ func handleList() error {
|
|
|
369
384
|
return nil
|
|
370
385
|
}
|
|
371
386
|
|
|
372
|
-
func handleUse() error {
|
|
373
|
-
flag.
|
|
387
|
+
func handleUse(args []string, socketPath string) error {
|
|
388
|
+
fs := flag.NewFlagSet("use", flag.ContinueOnError)
|
|
389
|
+
fs.SetOutput(os.Stderr)
|
|
374
390
|
|
|
375
|
-
|
|
391
|
+
fs.Parse(args)
|
|
392
|
+
|
|
393
|
+
if fs.NArg() < 1 {
|
|
376
394
|
return fmt.Errorf("session ID required")
|
|
377
395
|
}
|
|
378
|
-
sessionIDStr :=
|
|
396
|
+
sessionIDStr := fs.Arg(0)
|
|
379
397
|
|
|
380
398
|
params := protocol.UseParams{
|
|
381
399
|
SessionID: sessionIDStr,
|
|
382
400
|
}
|
|
383
401
|
|
|
384
|
-
_, err := sendRequest("use", params)
|
|
402
|
+
_, err := sendRequest(socketPath, "use", params)
|
|
385
403
|
if err != nil {
|
|
386
404
|
return err
|
|
387
405
|
}
|
|
@@ -390,13 +408,16 @@ func handleUse() error {
|
|
|
390
408
|
return nil
|
|
391
409
|
}
|
|
392
410
|
|
|
393
|
-
func handleForward() error {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
func handleForward(args []string, socketPath string) error {
|
|
412
|
+
fs := flag.NewFlagSet("forward", flag.ContinueOnError)
|
|
413
|
+
fs.SetOutput(os.Stderr)
|
|
414
|
+
|
|
415
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
416
|
+
local := fs.Int("l", 0, "Local port")
|
|
417
|
+
remote := fs.Int("r", 0, "Remote port")
|
|
418
|
+
isRemote := fs.Bool("R", false, "Remote port forward")
|
|
398
419
|
|
|
399
|
-
|
|
420
|
+
fs.Parse(args)
|
|
400
421
|
|
|
401
422
|
if *local == 0 || *remote == 0 {
|
|
402
423
|
return fmt.Errorf("local and remote ports are required")
|
|
@@ -414,7 +435,7 @@ func handleForward() error {
|
|
|
414
435
|
Remote: *remote,
|
|
415
436
|
}
|
|
416
437
|
|
|
417
|
-
result, err := sendRequest("forward", params)
|
|
438
|
+
result, err := sendRequest(socketPath, "forward", params)
|
|
418
439
|
if err != nil {
|
|
419
440
|
return err
|
|
420
441
|
}
|
|
@@ -428,8 +449,8 @@ func handleForward() error {
|
|
|
428
449
|
return nil
|
|
429
450
|
}
|
|
430
451
|
|
|
431
|
-
func handleForwards() error {
|
|
432
|
-
result, err := sendRequest("forwards", nil)
|
|
452
|
+
func handleForwards(socketPath string) error {
|
|
453
|
+
result, err := sendRequest(socketPath, "forwards", nil)
|
|
433
454
|
if err != nil {
|
|
434
455
|
return err
|
|
435
456
|
}
|
|
@@ -452,19 +473,22 @@ func handleForwards() error {
|
|
|
452
473
|
return nil
|
|
453
474
|
}
|
|
454
475
|
|
|
455
|
-
func handleForwardClose() error {
|
|
456
|
-
flag.
|
|
476
|
+
func handleForwardClose(args []string, socketPath string) error {
|
|
477
|
+
fs := flag.NewFlagSet("forward-close", flag.ContinueOnError)
|
|
478
|
+
fs.SetOutput(os.Stderr)
|
|
479
|
+
|
|
480
|
+
fs.Parse(args)
|
|
457
481
|
|
|
458
|
-
if
|
|
482
|
+
if fs.NArg() < 1 {
|
|
459
483
|
return fmt.Errorf("forward ID required")
|
|
460
484
|
}
|
|
461
|
-
forwardID :=
|
|
485
|
+
forwardID := fs.Arg(0)
|
|
462
486
|
|
|
463
487
|
params := protocol.ForwardCloseParams{
|
|
464
488
|
ForwardID: forwardID,
|
|
465
489
|
}
|
|
466
490
|
|
|
467
|
-
_, err := sendRequest("forward_close", params)
|
|
491
|
+
_, err := sendRequest(socketPath, "forward_close", params)
|
|
468
492
|
if err != nil {
|
|
469
493
|
return err
|
|
470
494
|
}
|
|
@@ -473,19 +497,22 @@ func handleForwardClose() error {
|
|
|
473
497
|
return nil
|
|
474
498
|
}
|
|
475
499
|
|
|
476
|
-
func handleSCP() error {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
isDownload := flag.Bool("get", false, "Download mode (remote->local)")
|
|
500
|
+
func handleSCP(args []string, socketPath string) error {
|
|
501
|
+
fs := flag.NewFlagSet("scp", flag.ContinueOnError)
|
|
502
|
+
fs.SetOutput(os.Stderr)
|
|
480
503
|
|
|
481
|
-
|
|
504
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
505
|
+
isUpload := fs.Bool("put", false, "Upload mode (local->remote)")
|
|
506
|
+
isDownload := fs.Bool("get", false, "Download mode (remote->local)")
|
|
482
507
|
|
|
483
|
-
|
|
508
|
+
fs.Parse(args)
|
|
509
|
+
|
|
510
|
+
if fs.NArg() < 2 {
|
|
484
511
|
return fmt.Errorf("source and destination paths required")
|
|
485
512
|
}
|
|
486
513
|
|
|
487
|
-
source :=
|
|
488
|
-
dest :=
|
|
514
|
+
source := fs.Arg(0)
|
|
515
|
+
dest := fs.Arg(1)
|
|
489
516
|
|
|
490
517
|
if !*isUpload && !*isDownload {
|
|
491
518
|
return fmt.Errorf("must specify -put or -get")
|
|
@@ -498,7 +525,7 @@ func handleSCP() error {
|
|
|
498
525
|
IsUpload: *isUpload,
|
|
499
526
|
}
|
|
500
527
|
|
|
501
|
-
result, err := sendRequest("scp", params)
|
|
528
|
+
result, err := sendRequest(socketPath, "scp", params)
|
|
502
529
|
if err != nil {
|
|
503
530
|
return err
|
|
504
531
|
}
|
|
@@ -517,12 +544,15 @@ func handleSCP() error {
|
|
|
517
544
|
return nil
|
|
518
545
|
}
|
|
519
546
|
|
|
520
|
-
func handleSFTP() error {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
547
|
+
func handleSFTP(args []string, socketPath string) error {
|
|
548
|
+
fs := flag.NewFlagSet("sftp", flag.ContinueOnError)
|
|
549
|
+
fs.SetOutput(os.Stderr)
|
|
550
|
+
|
|
551
|
+
sessionID := fs.String("s", "", "Session ID")
|
|
552
|
+
command := fs.String("c", "", "SFTP command (ls, mkdir, rm)")
|
|
553
|
+
path := fs.String("p", ".", "Path")
|
|
524
554
|
|
|
525
|
-
|
|
555
|
+
fs.Parse(args)
|
|
526
556
|
|
|
527
557
|
if *command == "" {
|
|
528
558
|
return fmt.Errorf("SFTP command required (-c ls|mkdir|rm)")
|
|
@@ -535,7 +565,7 @@ func handleSFTP() error {
|
|
|
535
565
|
Command: "ls",
|
|
536
566
|
Path: *path,
|
|
537
567
|
}
|
|
538
|
-
result, err := sendRequest("sftp_list", params)
|
|
568
|
+
result, err := sendRequest(socketPath, "sftp_list", params)
|
|
539
569
|
if err != nil {
|
|
540
570
|
return err
|
|
541
571
|
}
|
|
@@ -555,7 +585,7 @@ func handleSFTP() error {
|
|
|
555
585
|
Command: "mkdir",
|
|
556
586
|
Path: *path,
|
|
557
587
|
}
|
|
558
|
-
_, err := sendRequest("sftp_mkdir", params)
|
|
588
|
+
_, err := sendRequest(socketPath, "sftp_mkdir", params)
|
|
559
589
|
if err != nil {
|
|
560
590
|
return err
|
|
561
591
|
}
|
|
@@ -567,7 +597,7 @@ func handleSFTP() error {
|
|
|
567
597
|
Command: "rm",
|
|
568
598
|
Path: *path,
|
|
569
599
|
}
|
|
570
|
-
_, err := sendRequest("sftp_remove", params)
|
|
600
|
+
_, err := sendRequest(socketPath, "sftp_remove", params)
|
|
571
601
|
if err != nil {
|
|
572
602
|
return err
|
|
573
603
|
}
|
|
@@ -585,8 +615,8 @@ func printUsage() {
|
|
|
585
615
|
|
|
586
616
|
Usage:
|
|
587
617
|
gssh connect -u user -h host [-p port] [-i key_path] [-P password] [--ask-pass]
|
|
588
|
-
gssh disconnect [
|
|
589
|
-
gssh reconnect [
|
|
618
|
+
gssh disconnect [session_id]
|
|
619
|
+
gssh reconnect [session_id]
|
|
590
620
|
gssh exec [-s session_id] [-S password | --ask-sudo-pass] "command"
|
|
591
621
|
gssh list
|
|
592
622
|
gssh use <session_id>
|
|
@@ -621,16 +651,14 @@ Options:
|
|
|
621
651
|
Examples:
|
|
622
652
|
gssh connect -u admin -h 192.168.1.1 -P password
|
|
623
653
|
gssh exec "ls -la"
|
|
654
|
+
gssh reconnect 549b6eff-f62c-4dae-a7e9-298815233cf4
|
|
624
655
|
gssh forward -l 8080 -r 80
|
|
625
|
-
gssh forward -l 9000 -r 3000 -R
|
|
626
656
|
gssh scp -put local.txt /home/user/remote.txt
|
|
627
|
-
gssh sftp -c ls -p /home/user
|
|
628
657
|
`, version)
|
|
629
658
|
}
|
|
630
659
|
|
|
631
660
|
// Restore terminal on exit
|
|
632
661
|
func init() {
|
|
633
|
-
// Setup cleanup to restore terminal state on unexpected exit
|
|
634
662
|
c := make(chan os.Signal, 1)
|
|
635
663
|
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
|
636
664
|
go func() {
|
|
@@ -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
|
}
|
|
@@ -65,7 +65,6 @@ func needsShell(cmd string) bool {
|
|
|
65
65
|
|
|
66
66
|
// Check for redirection
|
|
67
67
|
if strings.ContainsAny(cmd, "><") {
|
|
68
|
-
// Make sure it's not in a string context
|
|
69
68
|
if strings.Contains(cmd, ">") || strings.Contains(cmd, "<") {
|
|
70
69
|
return true
|
|
71
70
|
}
|
|
@@ -150,6 +149,11 @@ func (m *Manager) Connect(user, host string, port int, password, keyPath string)
|
|
|
150
149
|
|
|
151
150
|
// Disconnect closes a session
|
|
152
151
|
func (m *Manager) Disconnect(sessionID string) error {
|
|
152
|
+
// Use default session if not specified
|
|
153
|
+
if sessionID == "" {
|
|
154
|
+
sessionID = m.defaultID
|
|
155
|
+
}
|
|
156
|
+
|
|
153
157
|
m.mu.Lock()
|
|
154
158
|
defer m.mu.Unlock()
|
|
155
159
|
|
|
@@ -174,6 +178,11 @@ func (m *Manager) Disconnect(sessionID string) error {
|
|
|
174
178
|
|
|
175
179
|
// Reconnect reconnects a session
|
|
176
180
|
func (m *Manager) Reconnect(sessionID string) (*protocol.Session, error) {
|
|
181
|
+
// Use default session if not specified
|
|
182
|
+
if sessionID == "" {
|
|
183
|
+
sessionID = m.defaultID
|
|
184
|
+
}
|
|
185
|
+
|
|
177
186
|
m.mu.Lock()
|
|
178
187
|
ms, ok := m.sessions[sessionID]
|
|
179
188
|
m.mu.Unlock()
|
|
@@ -215,7 +224,7 @@ func (m *Manager) Reconnect(sessionID string) (*protocol.Session, error) {
|
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
// Exec executes a command on a session
|
|
218
|
-
func (m *Manager) Exec(sessionID, command string) (*protocol.ExecResult, error) {
|
|
227
|
+
func (m *Manager) Exec(sessionID, command string, timeout int) (*protocol.ExecResult, error) {
|
|
219
228
|
m.mu.RLock()
|
|
220
229
|
var ms *ManagedSession
|
|
221
230
|
if sessionID != "" {
|
|
@@ -246,12 +255,38 @@ func (m *Manager) Exec(sessionID, command string) (*protocol.ExecResult, error)
|
|
|
246
255
|
// 检测是否需要通过 shell 执行
|
|
247
256
|
fullCmd := command
|
|
248
257
|
if needsShell(command) {
|
|
249
|
-
|
|
258
|
+
// 使用 /bin/sh 更通用
|
|
259
|
+
fullCmd = fmt.Sprintf("/bin/sh -c %q", command)
|
|
260
|
+
}
|
|
261
|
+
|
|
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)
|
|
250
286
|
}
|
|
251
287
|
|
|
252
|
-
output, err := session.CombinedOutput(fullCmd)
|
|
253
288
|
if err != nil {
|
|
254
|
-
exitErr, ok
|
|
289
|
+
exitErr, ok = err.(*ssh.ExitError)
|
|
255
290
|
if ok {
|
|
256
291
|
ms.LastCmd = command
|
|
257
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
|