apc-p15-tool/pkg/apcssh/client.go
Greg T. Wallace 06c9263bc4 ssh: breakout ups ssh to its own package
This was done for clearer separation of function. A subsequent update will (hopefully) make the SSL command more robust so it works for both NMC2 and NMC3.

The method for sending shell commands was also updated to use an interactive shell instead. This allows capturing responses of the commands which will be needed to deduce if devices are NMC2 or NMC3.
2024-06-06 22:52:54 -04:00

134 lines
3.6 KiB
Go

package apcssh
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"net"
"runtime"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const (
apcSSHVer = 1
sshTimeout = 90 * time.Second
)
// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
// the same. Therefore, write a custom implementation instead of relying on
// something like github.com/bramvdbogaerde/go-scp
type Config struct {
Hostname string
Username string
Password string
ServerFingerprint string
InsecureCipher bool
}
// Client is an APC UPS SSH client
type Client struct {
hostname string
sshCfg *ssh.ClientConfig
}
// New creates a new SSH Client for the APC UPS.
func New(cfg *Config) (*Client, error) {
// make host key callback
hk := func(_hostname string, _remote net.Addr, key ssh.PublicKey) error {
// calculate server's key's SHA256
hasher := sha256.New()
_, err := hasher.Write(key.Marshal())
if err != nil {
return err
}
actualHash := hasher.Sum(nil)
// log fingerprint for debugging
actualHashB64 := base64.RawStdEncoding.EncodeToString(actualHash)
actualHashHex := hex.EncodeToString(actualHash)
// check for fingerprint match (b64 or hex)
if actualHashB64 != cfg.ServerFingerprint && actualHashHex != cfg.ServerFingerprint {
log.Printf("apcssh: remote server key fingerprint (b64): %s", actualHashB64)
log.Printf("apcssh: remote server key fingerprint (hex): %s", actualHashHex)
return errors.New("apcssh: fingerprint didn't match")
}
return nil
}
// kex algos
// see defaults: https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.18.0:ssh/common.go;l=62
kexAlgos := []string{
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1",
}
// extra for some apc ups
kexAlgos = append(kexAlgos, "diffie-hellman-group-exchange-sha256")
// ciphers
// see defaults: https://cs.opensource.google/go/x/crypto/+/master:ssh/common.go;l=37
ciphers := []string{
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
}
// insecure cipher options?
if cfg.InsecureCipher {
log.Println("WARNING: insecure ciphers are enabled (--insecurecipher). SSH with an insecure cipher is NOT secure and should NOT be used.")
ciphers = append(ciphers, "aes128-cbc", "3des-cbc")
}
// install file on UPS
// ssh config
config := &ssh.ClientConfig{
User: cfg.Username,
Auth: []ssh.AuthMethod{
ssh.Password(cfg.Password),
},
// APC seems to require `Client Version` string to start with "SSH-2" and must be at least
// 13 characters long
// working examples from other clients:
// ClientVersion: "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6",
// ClientVersion: "SSH-2.0-PuTTY_Release_0.80",
ClientVersion: fmt.Sprintf("SSH-2.0-apcssh_v%d %s-%s", apcSSHVer, runtime.GOOS, runtime.GOARCH),
Config: ssh.Config{
KeyExchanges: kexAlgos,
Ciphers: ciphers,
},
HostKeyCallback: hk,
// reasonable timeout for file copy
Timeout: sshTimeout,
}
// if hostname missing a port, add default
if !strings.Contains(cfg.Hostname, ":") {
cfg.Hostname = cfg.Hostname + ":22"
}
// connect to ups over SSH (to verify everything works)
sshClient, err := ssh.Dial("tcp", cfg.Hostname, config)
if err != nil {
return nil, err
}
_ = sshClient.Close()
// return Client (note: new ssh Dial will be done for each action as the UPS
// seems to not do well with more than one Session per Dial)
return &Client{
hostname: cfg.Hostname,
sshCfg: config,
}, nil
}