Files
swanadiva fd7361aa14 Sprint 6: JSON file persistence + real SSH terminal + V1 crypto/knownhosts integration
- 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
2026-07-07 13:53:50 +07:00

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))
}