add install function

install pem files directly to an apc ups
This commit is contained in:
Greg T. Wallace 2024-02-02 18:35:21 -05:00
parent 4c154b2f27
commit a089d12c87
9 changed files with 392 additions and 51 deletions

5
go.mod
View file

@ -9,7 +9,10 @@ require (
golang.org/x/crypto v0.18.0 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 replace apc-p15-tool/cmd => /cmd

4
go.sum
View file

@ -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= 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 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -31,27 +31,21 @@ func Start() {
logger: makeZapLogger(&initLogLevel), logger: makeZapLogger(&initLogLevel),
} }
// get config // log start
app.getConfig() app.logger.Infof("apc-p15-tool v%s", appVersion)
// get & parse config
err := app.getConfig()
// re-init logger with configured log level // re-init logger with configured log level
app.logger = makeZapLogger(app.config.logLevel) app.logger = makeZapLogger(app.config.logLevel)
// log start // deal with config err (after logger re-init)
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))
if err != nil { if err != nil {
exitCode = 1 exitCode := 0
if errors.Is(err, ff.ErrHelp) { if errors.Is(err, ff.ErrHelp) {
// help explicitly requested // help explicitly requested
exitCode = 0
app.logger.Info("\n\n", ffhelp.Command(app.cmd)) app.logger.Info("\n\n", ffhelp.Command(app.cmd))
} else if errors.Is(err, ff.ErrDuplicateFlag) || } else if errors.Is(err, ff.ErrDuplicateFlag) ||
@ -59,13 +53,28 @@ func Start() {
errors.Is(err, ff.ErrNoExec) || errors.Is(err, ff.ErrNoExec) ||
errors.Is(err, ErrExtraArgs) { errors.Is(err, ErrExtraArgs) {
// other error that suggests user needs to see help // other error that suggests user needs to see help
exitCode = 1
app.logger.Error(err) app.logger.Error(err)
app.logger.Info("\n\n", ffhelp.Command(app.cmd)) app.logger.Info("\n\n", ffhelp.Command(app.cmd))
} else { } else {
// any other error // any other error
exitCode = 1
app.logger.Error(err) 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") app.logger.Info("apc-p15-tool done")

View file

@ -1,7 +1,6 @@
package app package app
import ( import (
"apc-p15-tool/pkg/pkcs15"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -29,42 +28,13 @@ func (app *app) cmdCreate(_ context.Context, args []string) error {
} }
// validation done // validation done
app.logger.Infof("create: making apc p15 file from pem files")
// Read in PEM files // make p15 file
keyPem, err := os.ReadFile(*app.config.create.keyPemFilePath) apcFile, err := app.pemToAPCP15(*app.config.create.keyPemFilePath, *app.config.create.certPemFilePath, "create")
if err != nil { 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) // determine file name (should already be done by flag parsing, but avoid nil just in case)
fileName := createDefaultOutFilePath fileName := createDefaultOutFilePath
if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" { if app.config.create.outFilePath != nil && *app.config.create.outFilePath != "" {

131
pkg/app/cmd_install.go Normal file
View file

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

View file

@ -2,6 +2,7 @@ package app
import ( import (
"errors" "errors"
"os"
"github.com/peterbourgon/ff/v4" "github.com/peterbourgon/ff/v4"
) )
@ -18,18 +19,26 @@ type config struct {
certPemFilePath *string certPemFilePath *string
outFilePath *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, // getConfig returns the app's configuration from either command line args,
// or environment variables // or environment variables
func (app *app) getConfig() { func (app *app) getConfig() error {
// make config // make config
cfg := &config{} cfg := &config{}
// commands: // commands:
// create // create
// install
// TODO: // TODO:
// upload
// unpack (both key & key+cert) // unpack (both key & key+cert)
// apc-p15-tool -- root command // apc-p15-tool -- root command
@ -61,7 +70,33 @@ func (app *app) getConfig() {
rootCmd.Subcommands = append(rootCmd.Subcommands, createCmd) rootCmd.Subcommands = append(rootCmd.Subcommands, createCmd)
// set app cmd & cfg // install -- subcommand
app.cmd = rootCmd 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.config = cfg
app.cmd = rootCmd
err := app.cmd.Parse(os.Args[1:], ff.WithEnvVarPrefix(environmentVarPrefix))
if err != nil {
return err
}
return nil
} }

50
pkg/app/pem_to_p15.go Normal file
View file

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

105
pkg/app/scp.go Normal file
View file

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

34
pkg/app/scp_response.go Normal file
View file

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