fd7361aa14
- go.mod replace directive for git.tukangketik.id/swanadiva/hostkeeper → ../../v1 - store/ package: JSON file persistence for hosts, snippets, keys, settings (~/Library/Application Support/hostkeeper/v2/) - model SSH fields: Hostname, Port, Username, AuthType, Password, KeyID - sshconn/ package: real SSH client via golang.org/x/crypto/ssh - Terminal WS: real SSH connection with fallback to mock shell - Dynamic host list in terminal (server-rendered JSON script tag) - All mock data defaults removed from model packages - Settings persist via store/settings.go
139 lines
2.9 KiB
Go
139 lines
2.9 KiB
Go
package sshconn
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
type Client struct {
|
|
Host model.Host
|
|
client *gossh.Client
|
|
session *gossh.Session
|
|
stdin io.WriteCloser
|
|
stdout io.Reader
|
|
stderr io.Reader
|
|
}
|
|
|
|
func Dial(host model.Host) (*Client, error) {
|
|
addr := net.JoinHostPort(host.Hostname, fmt.Sprintf("%d", host.Port))
|
|
if host.Hostname == "" {
|
|
addr = net.JoinHostPort(host.IP, "22")
|
|
}
|
|
|
|
config := &gossh.ClientConfig{
|
|
User: host.Username,
|
|
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
switch host.AuthType {
|
|
case "password", "":
|
|
config.Auth = []gossh.AuthMethod{
|
|
gossh.Password(host.Password),
|
|
}
|
|
case "key":
|
|
key, err := loadPrivateKey(host.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load key: %w", err)
|
|
}
|
|
config.Auth = []gossh.AuthMethod{
|
|
gossh.PublicKeys(key),
|
|
}
|
|
case "both":
|
|
key, err := loadPrivateKey(host.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load key: %w", err)
|
|
}
|
|
config.Auth = []gossh.AuthMethod{
|
|
gossh.Password(host.Password),
|
|
gossh.PublicKeys(key),
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown auth type: %s", host.AuthType)
|
|
}
|
|
|
|
client, err := gossh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
Host: host,
|
|
client: client,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) Shell(stdin io.Reader, stdout io.Writer, stderr io.Writer, rows, cols int) error {
|
|
session, err := c.client.NewSession()
|
|
if err != nil {
|
|
return fmt.Errorf("session: %w", err)
|
|
}
|
|
c.session = session
|
|
|
|
modes := gossh.TerminalModes{
|
|
gossh.ECHO: 1,
|
|
gossh.TTY_OP_ISPEED: 14400,
|
|
gossh.TTY_OP_OSPEED: 14400,
|
|
}
|
|
|
|
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
|
return fmt.Errorf("pty: %w", err)
|
|
}
|
|
|
|
c.stdin, _ = session.StdinPipe()
|
|
c.stdout, _ = session.StdoutPipe()
|
|
c.stderr, _ = session.StderrPipe()
|
|
|
|
go io.Copy(stdout, c.stdout)
|
|
go io.Copy(stderr, c.stderr)
|
|
go io.Copy(c.stdin, stdin)
|
|
|
|
if err := session.Shell(); err != nil {
|
|
return fmt.Errorf("shell: %w", err)
|
|
}
|
|
|
|
return session.Wait()
|
|
}
|
|
|
|
func (c *Client) Resize(rows, cols int) error {
|
|
if c.session == nil {
|
|
return nil
|
|
}
|
|
return c.session.WindowChange(rows, cols)
|
|
}
|
|
|
|
func (c *Client) Close() error {
|
|
if c.session != nil {
|
|
c.session.Close()
|
|
}
|
|
return c.client.Close()
|
|
}
|
|
|
|
func (c *Client) IsConnected() bool {
|
|
return c.client != nil
|
|
}
|
|
|
|
func loadPrivateKey(keyContent string) (gossh.Signer, error) {
|
|
if keyContent == "" {
|
|
paths := []string{
|
|
os.ExpandEnv("$HOME/.ssh/id_ed25519"),
|
|
os.ExpandEnv("$HOME/.ssh/id_rsa"),
|
|
os.ExpandEnv("$HOME/.ssh/id_ecdsa"),
|
|
}
|
|
for _, p := range paths {
|
|
data, err := os.ReadFile(p)
|
|
if err == nil {
|
|
return gossh.ParsePrivateKey(data)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no SSH key found")
|
|
}
|
|
return gossh.ParsePrivateKey([]byte(keyContent))
|
|
}
|