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.
This commit is contained in:
Greg T. Wallace 2024-06-06 22:51:12 -04:00
parent 41efc56c62
commit 06c9263bc4
11 changed files with 444 additions and 284 deletions

View file

@ -1,16 +1,10 @@
package app
import (
"apc-p15-tool/pkg/apcssh"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"runtime"
"golang.org/x/crypto/ssh"
)
// cmdInstall is the app's command to create apc p15 file content from key and cert
@ -52,100 +46,29 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
// validation done
// make p15 file
apcFile, _, err := app.pemToAPCP15s(keyPem, certPem, "install")
keyCertP15, _, err := app.pemToAPCP15s(keyPem, certPem, "install")
if err != nil {
return err
}
// 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)
app.debugLogger.Printf("ssh: remote server key fingerprint (b64): %s", actualHashB64)
app.debugLogger.Printf("ssh: remote server key fingerprint (hex): %s", actualHashHex)
// allow base64 format
if actualHashB64 == *app.config.install.fingerprint {
return nil
}
// allow hex format
if actualHashHex == *app.config.install.fingerprint {
return nil
}
return errors.New("ssh: fingerprint didn't match")
// make APC SSH client
cfg := &apcssh.Config{
Hostname: *app.config.install.hostAndPort,
Username: *app.config.install.username,
Password: *app.config.install.password,
ServerFingerprint: *app.config.install.fingerprint,
InsecureCipher: *app.config.install.insecureCipher,
}
// 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 app.config.install.insecureCipher != nil && *app.config.install.insecureCipher {
app.stdLogger.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: *app.config.install.username,
Auth: []ssh.AuthMethod{
ssh.Password(*app.config.install.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-apc-p15-tool_v%s %s-%s", appVersion, runtime.GOOS, runtime.GOARCH),
Config: ssh.Config{
KeyExchanges: kexAlgos,
Ciphers: ciphers,
// MACs: []string{"hmac-sha2-256"},
},
// HostKeyAlgorithms: []string{"ssh-rsa"},
HostKeyCallback: hk,
// reasonable timeout for file copy
Timeout: sshScpTimeout,
}
// connect to ups over SSH
client, err := ssh.Dial("tcp", *app.config.install.hostAndPort, config)
client, err := apcssh.New(cfg)
if err != nil {
return fmt.Errorf("install: failed to connect to host (%w)", err)
}
defer client.Close()
// send file to UPS
err = sshScpSendFileToUPS(client, apcFile)
// install SSL Cert
err = client.InstallSSLCert(keyCertP15)
if err != nil {
return fmt.Errorf("install: failed to send p15 file to ups over scp (%w)", err)
return fmt.Errorf("install: failed to send file to ups over scp (%w)", err)
}
// installed
@ -155,16 +78,7 @@ func (app *app) cmdInstall(cmdCtx context.Context, args []string) error {
if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
app.stdLogger.Println("install: sending restart command")
// connect to ups over SSH
// opening a second session doesn't seem to work with my NMC2 for some reason, so make
// a new connection instead
client, err = ssh.Dial("tcp", *app.config.install.hostAndPort, config)
if err != nil {
return fmt.Errorf("install: failed to reconnect to host to send webui restart command (%w)", err)
}
defer client.Close()
err = sshResetUPSWebUI(client)
err = client.RestartWebUI()
if err != nil {
return fmt.Errorf("install: failed to send webui restart command (%w)", err)
}

View file

@ -1,45 +0,0 @@
package app
import (
"errors"
"fmt"
"golang.org/x/crypto/ssh"
)
// sshResetUPSWebUI sends a command to the UPS to restart the WebUI. This
// command is supposed to be required to load the new cert, but that
// doesn't seem to be true (at least it isn't on my UPS). Adding the
// option though, in case other UPS might need it.
func sshResetUPSWebUI(client *ssh.Client) error {
// make session to use for restart command
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh: restart: failed to create session (%w)", err)
}
defer session.Close()
// start shell
err = session.Shell()
if err != nil {
return fmt.Errorf("ssh: restart: failed to start shell (%w)", err)
}
// execure reboot via SendRequest
payload := []byte("reboot -Y")
payloadLen := uint8(len(payload))
payload = append([]byte{0, 0, 0, payloadLen}, payload...)
ok, err := session.SendRequest("exec", true, payload)
if err != nil {
return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
}
if !ok {
return errors.New("ssh: scp: execute scp cmd not ok")
}
// don't read remote output, as nothing interesting actually outputs
// done
return nil
}

View file

@ -1,34 +0,0 @@
package app
import (
"bufio"
"fmt"
"io"
)
// sshCheckResponse reads the output from the remote and returns an error
// if the remote output was not 0
func sshCheckResponse(remoteOutPipe io.Reader) error {
buffer := make([]uint8, 1)
_, err := remoteOutPipe.Read(buffer)
if err != nil {
return fmt.Errorf("ssh: failed to read output buffer (%w)", err)
}
responseType := buffer[0]
message := ""
if responseType > 0 {
bufferedReader := bufio.NewReader(remoteOutPipe)
message, err = bufferedReader.ReadString('\n')
if err != nil {
return fmt.Errorf("ssh: failed to read output buffer (%w)", err)
}
}
// if not 0 (aka OK)
if responseType != 0 {
return fmt.Errorf("ssh: remote returned error (%d: %s)", responseType, message)
}
return nil
}

View file

@ -1,105 +0,0 @@
package app
import (
"bytes"
"errors"
"fmt"
"io"
"path"
"time"
"golang.org/x/crypto/ssh"
)
// APC UPS won't except Go's SSH "Run()" command as the format isn't quite
// the same. Therefore, write a custom implementation to send the desired
// command instead of relying on something like github.com/bramvdbogaerde/go-scp
const (
sshScpP15Destination = "/ssl/defaultcert.p15"
sshScpP15PermissionsStr = "0600"
sshScpTimeout = 90 * time.Second
)
// sshScpSendFileToUPS sends the p15File to the APC UPS via the SCP protocol. it is
// automatically placed in the correct directory and will overwrite any existing
// file
func sshScpSendFileToUPS(client *ssh.Client, p15File []byte) error {
// make session to use for SCP
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("ssh: scp: failed to create session (%w)", err)
}
defer session.Close()
// attach pipes
out, err := session.StdoutPipe()
if err != nil {
return err
}
w, err := session.StdinPipe()
if err != nil {
return err
}
defer w.Close()
// send execute cmd --
// build cmd to send as request
// Go implementation sends additional 0x22 bytes when using Run() (as
// compared to putty's scp tool). these additional bytes seem to cause the
// apc ups to fail execution of the command
payload := []byte(fmt.Sprintf("scp -q -t %s", sshScpP15Destination))
payloadLen := uint8(len(payload))
payload = append([]byte{0, 0, 0, payloadLen}, payload...)
ok, err := session.SendRequest("exec", true, payload)
if err != nil {
return fmt.Errorf("ssh: scp: failed to execute scp cmd (%w)", err)
}
if !ok {
return errors.New("ssh: scp: execute scp cmd not ok")
}
// check remote response
// Note: File upload may not work if the client doesn't actually read from
// the remote output.
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send scp cmd (bad remote response) (%w)", err)
}
// just file name (without path)
filename := path.Base(sshScpP15Destination)
// send file header
_, err = fmt.Fprintln(w, "C"+sshScpP15PermissionsStr, len(p15File), filename)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file info (%w)", err)
}
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file info (bad remote response) (%w)", err)
}
// send actual file
_, err = io.Copy(w, bytes.NewReader(p15File))
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file(%w)", err)
}
// send file end
_, err = fmt.Fprint(w, "\x00")
if err != nil {
return fmt.Errorf("ssh: scp: failed to send final 00 byte (%w)", err)
}
err = sshCheckResponse(out)
if err != nil {
return fmt.Errorf("ssh: scp: failed to send file (bad remote response) (%w)", err)
}
// done
return nil
}