mirror of
https://github.com/gregtwallace/apc-p15-tool.git
synced 2025-01-22 08:14:08 +00:00
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:
parent
41efc56c62
commit
06c9263bc4
11 changed files with 444 additions and 284 deletions
2
go.mod
2
go.mod
|
@ -14,6 +14,8 @@ replace apc-p15-tool/cmd/install_only => /cmd/install_only
|
||||||
|
|
||||||
replace apc-p15-tool/cmd/tool => /cmd/tool
|
replace apc-p15-tool/cmd/tool => /cmd/tool
|
||||||
|
|
||||||
|
replace apc-p15-tool/pkg/apcssh => /pkg/apcssh
|
||||||
|
|
||||||
replace apc-p15-tool/pkg/app => /pkg/app
|
replace apc-p15-tool/pkg/app => /pkg/app
|
||||||
|
|
||||||
replace apc-p15-tool/pkg/pkcs15 => /pkg/pkcs15
|
replace apc-p15-tool/pkg/pkcs15 => /pkg/pkcs15
|
||||||
|
|
134
pkg/apcssh/client.go
Normal file
134
pkg/apcssh/client.go
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
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
|
||||||
|
}
|
24
pkg/apcssh/cmd_restartwebui.go
Normal file
24
pkg/apcssh/cmd_restartwebui.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package apcssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestartWebUI sends the APC command to restart the web ui
|
||||||
|
// WARNING: Sending a command directly after this one will cause issues.
|
||||||
|
// This command will cause SSH to also restart after a slight delay, therefore
|
||||||
|
// any command right after this will start to run but then get stuck / fail
|
||||||
|
// somewhere in the middle.
|
||||||
|
func (cli *Client) RestartWebUI() error {
|
||||||
|
result, err := cli.cmd("reboot -Y")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(result.code) != "e000" {
|
||||||
|
return fmt.Errorf("apcssh: failed to restart web ui (%s: %s)", result.code, result.codeText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
129
pkg/apcssh/scp.go
Normal file
129
pkg/apcssh/scp.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package apcssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadSCP uploads a file to the destination specified (e.g., "/ssl/file.key")
|
||||||
|
// containing the file content specified. An existing file at the destination
|
||||||
|
// will be overwritten without warning.
|
||||||
|
func (cli *Client) UploadSCP(destination string, fileContent []byte, filePermissions fs.FileMode) error {
|
||||||
|
// connect
|
||||||
|
sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to dial session (%w)", err)
|
||||||
|
}
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
// make session to use for SCP
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: 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", destination))
|
||||||
|
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("apcssh: scp: failed to execute scp cmd (%w)", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("apcssh: 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 = scpCheckResponse(out)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send scp cmd (bad remote response 1) (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// just file name (without path)
|
||||||
|
filename := path.Base(destination)
|
||||||
|
|
||||||
|
// send file header
|
||||||
|
_, err = fmt.Fprintln(w, "C"+fmt.Sprintf("%04o", filePermissions.Perm()), len(fileContent), filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send file info (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scpCheckResponse(out)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send file info (bad remote response 2) (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// send actual file
|
||||||
|
_, err = io.Copy(w, bytes.NewReader(fileContent))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send file(%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// send file end
|
||||||
|
_, err = fmt.Fprint(w, "\x00")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send final 00 byte (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = scpCheckResponse(out)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: scp: failed to send file (bad remote response 3) (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scpCheckResponse reads the output from the remote and returns an error
|
||||||
|
// if the remote output was not 0
|
||||||
|
func scpCheckResponse(remoteOutPipe io.Reader) error {
|
||||||
|
buffer := make([]uint8, 1)
|
||||||
|
_, err := remoteOutPipe.Read(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: 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("apcssh: failed to read output buffer (%w)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if not 0 (aka OK)
|
||||||
|
if responseType != 0 {
|
||||||
|
return fmt.Errorf("apcssh: remote returned error (%d: %s)", responseType, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
88
pkg/apcssh/shell.go
Normal file
88
pkg/apcssh/shell.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package apcssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// upsCmdResponse is a structure that holds all of a shell commands results
|
||||||
|
type upsCmdResponse struct {
|
||||||
|
command string
|
||||||
|
code string
|
||||||
|
codeText string
|
||||||
|
resultText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmd creates an interactive shell and executes the specified command
|
||||||
|
func (cli *Client) cmd(command string) (*upsCmdResponse, error) {
|
||||||
|
// connect
|
||||||
|
sshClient, err := ssh.Dial("tcp", cli.hostname, cli.sshCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to dial session (%w)", err)
|
||||||
|
}
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to create session (%w)", err)
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// pipes to send shell command to; and to receive repsonse
|
||||||
|
sshInput, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to make shell input pipe (%w)", err)
|
||||||
|
}
|
||||||
|
sshOutput, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to make shell output pipe (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// make scanner to read shell continuously
|
||||||
|
scanner := bufio.NewScanner(sshOutput)
|
||||||
|
scanner.Split(scanAPCShell)
|
||||||
|
|
||||||
|
// start interactive shell
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to start shell (%w)", err)
|
||||||
|
}
|
||||||
|
// discard the initial shell response (login message(s) / initial shell prompt)
|
||||||
|
for {
|
||||||
|
if token := scanner.Scan(); token {
|
||||||
|
_ = scanner.Bytes()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send command
|
||||||
|
_, err = fmt.Fprint(sshInput, command+"\n")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apcssh: failed to send shell command (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &upsCmdResponse{}
|
||||||
|
for {
|
||||||
|
if tkn := scanner.Scan(); tkn {
|
||||||
|
result := string(scanner.Bytes())
|
||||||
|
|
||||||
|
cmdIndx := strings.Index(result, "\n")
|
||||||
|
res.command = result[:cmdIndx-1]
|
||||||
|
result = result[cmdIndx+1:]
|
||||||
|
|
||||||
|
codeIndx := strings.Index(result, ": ")
|
||||||
|
res.code = result[:codeIndx]
|
||||||
|
result = result[codeIndx+2:]
|
||||||
|
|
||||||
|
codeTxtIndx := strings.Index(result, "\n")
|
||||||
|
res.codeText = result[:codeTxtIndx-1]
|
||||||
|
|
||||||
|
res.resultText = result[codeTxtIndx+1 : len(result)-2]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
38
pkg/apcssh/shell_helpers.go
Normal file
38
pkg/apcssh/shell_helpers.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package apcssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanAPCShell is a SplitFunc to capture shell output after each interactive
|
||||||
|
// shell command is run
|
||||||
|
func scanAPCShell(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
if atEOF && len(data) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regex for shell prompt (e.g., `apc@apc>`)
|
||||||
|
re := regexp.MustCompile(`(\r\n|\r|\n)[A-Za-z0-0.]+@[A-Za-z0-0.]+>`)
|
||||||
|
|
||||||
|
// find match for prompt
|
||||||
|
if index := re.FindStringIndex(string(data)); index != nil {
|
||||||
|
// advance starts after the prompt; token is everything before the prompt
|
||||||
|
return index[1], dropCR(data[0:index[0]]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're at EOF, we have a final, non-terminated line. Return it.
|
||||||
|
if atEOF {
|
||||||
|
return len(data), dropCR(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request more data.
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropCR drops a terminal \r from the data.
|
||||||
|
func dropCR(data []byte) []byte {
|
||||||
|
if len(data) > 0 && data[len(data)-1] == '\r' {
|
||||||
|
return data[0 : len(data)-1]
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
15
pkg/apcssh/ssl.go
Normal file
15
pkg/apcssh/ssl.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package apcssh
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// InstallSSLCert installs the specified p15 cert file on the UPS. This
|
||||||
|
// function currently only works on NMC2.
|
||||||
|
func (cli *Client) InstallSSLCert(keyCertP15 []byte) error {
|
||||||
|
// install NMC2 P15 file
|
||||||
|
err := cli.UploadSCP("/ssl/defaultcert.p15", keyCertP15, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apcssh: ssl cert install: failed to send file to ups over scp (%w)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -1,16 +1,10 @@
|
||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apc-p15-tool/pkg/apcssh"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdInstall is the app's command to create apc p15 file content from key and cert
|
// 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
|
// validation done
|
||||||
|
|
||||||
// make p15 file
|
// make p15 file
|
||||||
apcFile, _, err := app.pemToAPCP15s(keyPem, certPem, "install")
|
keyCertP15, _, err := app.pemToAPCP15s(keyPem, certPem, "install")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// make host key callback
|
// make APC SSH client
|
||||||
hk := func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
cfg := &apcssh.Config{
|
||||||
// calculate server's key's SHA256
|
Hostname: *app.config.install.hostAndPort,
|
||||||
hasher := sha256.New()
|
Username: *app.config.install.username,
|
||||||
_, err := hasher.Write(key.Marshal())
|
Password: *app.config.install.password,
|
||||||
if err != nil {
|
ServerFingerprint: *app.config.install.fingerprint,
|
||||||
return err
|
InsecureCipher: *app.config.install.insecureCipher,
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// kex algos
|
client, err := apcssh.New(cfg)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("install: failed to connect to host (%w)", err)
|
return fmt.Errorf("install: failed to connect to host (%w)", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
// send file to UPS
|
// install SSL Cert
|
||||||
err = sshScpSendFileToUPS(client, apcFile)
|
err = client.InstallSSLCert(keyCertP15)
|
||||||
if err != nil {
|
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
|
// 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 {
|
if app.config.install.restartWebUI != nil && *app.config.install.restartWebUI {
|
||||||
app.stdLogger.Println("install: sending restart command")
|
app.stdLogger.Println("install: sending restart command")
|
||||||
|
|
||||||
// connect to ups over SSH
|
err = client.RestartWebUI()
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("install: failed to send webui restart command (%w)", err)
|
return fmt.Errorf("install: failed to send webui restart command (%w)", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
Loading…
Reference in a new issue