From a089d12c8727b5af94fe66f5ecd953ef3b6fe9c2 Mon Sep 17 00:00:00 2001 From: "Greg T. Wallace" Date: Fri, 2 Feb 2024 18:35:21 -0500 Subject: [PATCH] add install function install pem files directly to an apc ups --- go.mod | 5 +- go.sum | 4 ++ pkg/app/app.go | 35 +++++++---- pkg/app/cmd_create.go | 36 +---------- pkg/app/cmd_install.go | 131 ++++++++++++++++++++++++++++++++++++++++ pkg/app/config.go | 43 +++++++++++-- pkg/app/pem_to_p15.go | 50 +++++++++++++++ pkg/app/scp.go | 105 ++++++++++++++++++++++++++++++++ pkg/app/scp_response.go | 34 +++++++++++ 9 files changed, 392 insertions(+), 51 deletions(-) create mode 100644 pkg/app/cmd_install.go create mode 100644 pkg/app/pem_to_p15.go create mode 100644 pkg/app/scp.go create mode 100644 pkg/app/scp_response.go diff --git a/go.mod b/go.mod index fe8c680..33e551b 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,10 @@ require ( golang.org/x/crypto v0.18.0 ) -require go.uber.org/multierr v1.11.0 // indirect +require ( + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.16.0 // indirect +) replace apc-p15-tool/cmd => /cmd diff --git a/go.sum b/go.sum index ceaba59..6f71e27 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,10 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/app/app.go b/pkg/app/app.go index 798f257..fa1d682 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -31,27 +31,21 @@ func Start() { logger: makeZapLogger(&initLogLevel), } - // get config - app.getConfig() + // log start + app.logger.Infof("apc-p15-tool v%s", appVersion) + + // get & parse config + err := app.getConfig() // re-init logger with configured log level app.logger = makeZapLogger(app.config.logLevel) - // log start - app.logger.Infof("apc-p15-tool v%s", appVersion) - - // get config - app.getConfig() - - // run it - exitCode := 0 - err := app.cmd.ParseAndRun(context.Background(), os.Args[1:], ff.WithEnvVarPrefix(environmentVarPrefix)) + // deal with config err (after logger re-init) if err != nil { - exitCode = 1 + exitCode := 0 if errors.Is(err, ff.ErrHelp) { // help explicitly requested - exitCode = 0 app.logger.Info("\n\n", ffhelp.Command(app.cmd)) } else if errors.Is(err, ff.ErrDuplicateFlag) || @@ -59,13 +53,28 @@ func Start() { errors.Is(err, ff.ErrNoExec) || errors.Is(err, ErrExtraArgs) { // other error that suggests user needs to see help + exitCode = 1 app.logger.Error(err) app.logger.Info("\n\n", ffhelp.Command(app.cmd)) } else { // any other error + exitCode = 1 app.logger.Error(err) } + + os.Exit(exitCode) + } + + // get config + app.getConfig() + + // run it + exitCode := 0 + err = app.cmd.Run(context.Background()) + if err != nil { + exitCode = 1 + app.logger.Error(err) } app.logger.Info("apc-p15-tool done") diff --git a/pkg/app/cmd_create.go b/pkg/app/cmd_create.go index 2cd0e74..e2e60e4 100644 --- a/pkg/app/cmd_create.go +++ b/pkg/app/cmd_create.go @@ -1,7 +1,6 @@ package app import ( - "apc-p15-tool/pkg/pkcs15" "context" "errors" "fmt" @@ -29,42 +28,13 @@ func (app *app) cmdCreate(_ context.Context, args []string) error { } // validation done - app.logger.Infof("create: making apc p15 file from pem files") - // Read in PEM files - keyPem, err := os.ReadFile(*app.config.create.keyPemFilePath) + // make p15 file + apcFile, err := app.pemToAPCP15(*app.config.create.keyPemFilePath, *app.config.create.certPemFilePath, "create") if err != nil { - return fmt.Errorf("create: failed to read key file (%s)", err) + return err } - certPem, err := os.ReadFile(*app.config.create.certPemFilePath) - if err != nil { - return fmt.Errorf("create: failed to read cert file (%s)", err) - } - - // make p15 struct - p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem) - if err != nil { - return fmt.Errorf("create: failed to parse pem files (%s)", err) - } - - app.logger.Infof("create: successfully loaded pem files") - - // make file bytes - p15File, err := p15.ToP15File() - if err != nil { - return fmt.Errorf("create: failed to make p15 file (%s)", err) - } - - // make header for file bytes - apcHeader, err := makeFileHeader(p15File) - if err != nil { - return fmt.Errorf("create: failed to make p15 file header (%s)", err) - } - - // combine header with file - apcFile := append(apcHeader, p15File...) - // determine file name (should already be done by flag parsing, but avoid nil just in case) fileName := createDefaultOutFilePath if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" { diff --git a/pkg/app/cmd_install.go b/pkg/app/cmd_install.go new file mode 100644 index 0000000..04978b4 --- /dev/null +++ b/pkg/app/cmd_install.go @@ -0,0 +1,131 @@ +package app + +import ( + "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 +// pem files and upload the p15 to the specified APC UPS +func (app *app) cmdInstall(cmdCtx context.Context, args []string) error { + // extra args == error + if len(args) != 0 { + return fmt.Errorf("install: failed, %w (%d)", ErrExtraArgs, len(args)) + } + + // must have username + if app.config.install.username == nil || *app.config.install.username == "" { + return errors.New("install: failed, username not specified") + } + + // must have password + if app.config.install.password == nil || *app.config.install.password == "" { + return errors.New("install: failed, password not specified") + } + + // must have fingerprint + if app.config.install.fingerprint == nil || *app.config.install.fingerprint == "" { + return errors.New("install: failed, fingerprint not specified") + } + + // key must be specified + if app.config.install.keyPemFilePath == nil || *app.config.install.keyPemFilePath == "" { + return errors.New("install: failed, key not specified") + } + + // cert must be specified + if app.config.install.certPemFilePath == nil || *app.config.install.certPemFilePath == "" { + return errors.New("install: failed, cert not specified") + } + + // host to install on must be specified + if app.config.install.hostAndPort == nil || *app.config.install.hostAndPort == "" { + return errors.New("install: failed, apc host not specified") + } + + // validation done + + // make p15 file + apcFile, err := app.pemToAPCP15(*app.config.install.keyPemFilePath, *app.config.install.certPemFilePath, "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.logger.Debugf("ssh: remote server key fingerprint (b64): %s", actualHashB64) + app.logger.Debugf("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") + } + + // 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 + // e.g. working from Ubuntu ssh: 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: []string{"ecdh-sha2-nistp256"}, + // Ciphers: []string{"aes128-ctr"}, + // MACs: []string{"hmac-sha2-256"}, + }, + // HostKeyAlgorithms: []string{"ssh-rsa"}, + HostKeyCallback: hk, + + // reasonable timeout for file copy + Timeout: scpTimeout, + } + + // connect to ups over SSH + client, err := ssh.Dial("tcp", *app.config.install.hostAndPort, config) + if err != nil { + return fmt.Errorf("install: failed to connect to host (%w)", err) + } + + // send file to UPS + err = scpSendFileToUPS(client, apcFile) + if err != nil { + return fmt.Errorf("install: failed to send p15 file to ups over scp (%w)", err) + } + + // done + app.logger.Infof("install: apc p15 file installed on %s", *app.config.install.hostAndPort) + + return nil +} diff --git a/pkg/app/config.go b/pkg/app/config.go index 31f0f92..c8d5dab 100644 --- a/pkg/app/config.go +++ b/pkg/app/config.go @@ -2,6 +2,7 @@ package app import ( "errors" + "os" "github.com/peterbourgon/ff/v4" ) @@ -18,18 +19,26 @@ type config struct { certPemFilePath *string outFilePath *string } + install struct { + keyPemFilePath *string + certPemFilePath *string + hostAndPort *string + fingerprint *string + username *string + password *string + } } // getConfig returns the app's configuration from either command line args, // or environment variables -func (app *app) getConfig() { +func (app *app) getConfig() error { // make config cfg := &config{} // commands: // create + // install // TODO: - // upload // unpack (both key & key+cert) // apc-p15-tool -- root command @@ -61,7 +70,33 @@ func (app *app) getConfig() { rootCmd.Subcommands = append(rootCmd.Subcommands, createCmd) - // set app cmd & cfg - app.cmd = rootCmd + // install -- subcommand + installFlags := ff.NewFlagSet("install").SetParent(rootFlags) + + cfg.install.keyPemFilePath = installFlags.StringLong("keyfile", "", "path and filename of the rsa-2048 key in pem format") + cfg.install.certPemFilePath = installFlags.StringLong("certfile", "", "path and filename of the certificate in pem format") + cfg.install.hostAndPort = installFlags.StringLong("apchost", "", "hostname:port of the apc ups to install the certificate on") + cfg.install.fingerprint = installFlags.StringLong("fingerprint", "", "the SHA256 fingerprint value of the ups' ssh server") + cfg.install.username = installFlags.StringLong("username", "", "username to login to the apc ups") + cfg.install.password = installFlags.StringLong("password", "", "password to login to the apc ups") + + installCmd := &ff.Command{ + Name: "install", + Usage: "apc-p15-tool upload --keyfile key.pem --certfile cert.pem --apchost example.com:22 --fingerprint 123abc --username apc --password test", + ShortHelp: "install the specified key and cert pem files on an apc ups (they will be converted to a comaptible p15 file)", + Flags: installFlags, + Exec: app.cmdInstall, + } + + rootCmd.Subcommands = append(rootCmd.Subcommands, installCmd) + + // set cfg & parse app.config = cfg + app.cmd = rootCmd + err := app.cmd.Parse(os.Args[1:], ff.WithEnvVarPrefix(environmentVarPrefix)) + if err != nil { + return err + } + + return nil } diff --git a/pkg/app/pem_to_p15.go b/pkg/app/pem_to_p15.go new file mode 100644 index 0000000..17297a1 --- /dev/null +++ b/pkg/app/pem_to_p15.go @@ -0,0 +1,50 @@ +package app + +import ( + "apc-p15-tool/pkg/pkcs15" + "fmt" + "os" +) + +// pemToAPCP15 reads the specified pem files and returns the apc p15 bytes +func (app *app) pemToAPCP15(keyFileName, certFileName, parentCmdName string) ([]byte, error) { + app.logger.Infof("%s: making apc p15 file from pem files", parentCmdName) + + // Read in PEM files + keyPem, err := os.ReadFile(keyFileName) + if err != nil { + return nil, fmt.Errorf("%s: failed to read key file (%w)", parentCmdName, err) + } + + certPem, err := os.ReadFile(certFileName) + if err != nil { + return nil, fmt.Errorf("%s: failed to read cert file (%w)", parentCmdName, err) + } + + // make p15 struct + p15, err := pkcs15.ParsePEMToPKCS15(keyPem, certPem) + if err != nil { + return nil, fmt.Errorf("%s: failed to parse pem files (%w)", parentCmdName, err) + } + + app.logger.Infof("%s: successfully loaded pem files", parentCmdName) + + // make file bytes + p15File, err := p15.ToP15File() + if err != nil { + return nil, fmt.Errorf("%s: failed to make p15 file (%w)", parentCmdName, err) + } + + // make header for file bytes + apcHeader, err := makeFileHeader(p15File) + if err != nil { + return nil, fmt.Errorf("%s: failed to make p15 file header (%w)", parentCmdName, err) + } + + // combine header with file + apcFile := append(apcHeader, p15File...) + + app.logger.Infof("%s: apc p15 file data succesfully generated", parentCmdName) + + return apcFile, nil +} diff --git a/pkg/app/scp.go b/pkg/app/scp.go new file mode 100644 index 0000000..2fa32a7 --- /dev/null +++ b/pkg/app/scp.go @@ -0,0 +1,105 @@ +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 ( + scpP15Destination = "/ssl/defaultcert.p15" + scpP15PermissionsStr = "0600" + + scpTimeout = 90 * time.Second +) + +// scpSendFileToUPS 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 scpSendFileToUPS(client *ssh.Client, p15File []byte) error { + // make session to use for SCP + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("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 -v -t %s", scpP15Destination)) + 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("scp: failed to execute scp cmd (%w)", err) + } + if !ok { + return errors.New("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("scp: failed to send scp cmd (bad remote response) (%w)", err) + } + + // just file name (without path) + filename := path.Base(scpP15Destination) + + // send file header + _, err = fmt.Fprintln(w, "C"+scpP15PermissionsStr, len(p15File), filename) + if err != nil { + return fmt.Errorf("scp: failed to send file info (%w)", err) + } + + err = scpCheckResponse(out) + if err != nil { + return fmt.Errorf("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("scp: failed to send file(%w)", err) + } + + // send file end + _, err = fmt.Fprint(w, "\x00") + if err != nil { + return fmt.Errorf("scp: failed to send final 00 byte (%w)", err) + } + + err = scpCheckResponse(out) + if err != nil { + return fmt.Errorf("scp: failed to send file (bad remote response) (%w)", err) + } + + // done + return nil +} diff --git a/pkg/app/scp_response.go b/pkg/app/scp_response.go new file mode 100644 index 0000000..9968366 --- /dev/null +++ b/pkg/app/scp_response.go @@ -0,0 +1,34 @@ +package app + +import ( + "bufio" + "fmt" + "io" +) + +// 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("scp: failed to make 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("scp: failed to read output buffer (%w)", err) + } + } + + // if not 0 (aka OK) + if responseType != 0 { + return fmt.Errorf("scp: remote returned error (%d: %s)", responseType, message) + } + + return nil +}