Serge Bazanski | caa1208 | 2023-02-16 14:54:04 +0100 | [diff] [blame] | 1 | package manager |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "errors" |
| 8 | "flag" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | |
| 14 | "github.com/packethost/packngo" |
| 15 | "golang.org/x/crypto/ssh" |
| 16 | "k8s.io/klog/v2" |
| 17 | |
| 18 | ecl "source.monogon.dev/cloud/shepherd/equinix/wrapngo" |
| 19 | ) |
| 20 | |
| 21 | var ( |
| 22 | NoSuchKey = errors.New("no such key") |
| 23 | ) |
| 24 | |
| 25 | // SharedConfig contains configuration options used by both the Initializer and |
| 26 | // Provisioner components of the Shepherd. In CLI scenarios, RegisterFlags should |
| 27 | // be called to configure this struct from CLI flags. Otherwise, this structure |
| 28 | // should be explicitly configured, as the default values are not valid. |
| 29 | type SharedConfig struct { |
| 30 | // ProjectId is the Equinix project UUID used by the manager. See Equinix API |
| 31 | // documentation for details. Must be set. |
| 32 | ProjectId string |
| 33 | |
| 34 | // Label specifies the ID to use when handling the Equinix-registered SSH key |
| 35 | // used to authenticate to newly created servers. Must be set. |
| 36 | KeyLabel string |
| 37 | |
| 38 | // myKey guards Key. |
| 39 | muKey sync.Mutex |
| 40 | |
| 41 | // SSH key to use when creating machines and then connecting to them. If not |
| 42 | // provided, it will be automatically loaded from KeyPersistPath, and if that |
| 43 | // doesn't exist either, it will be first generated and persisted there. |
| 44 | Key ed25519.PrivateKey |
| 45 | |
| 46 | // Path at which the SSH key will be loaded from and persisted to, if Key is not |
| 47 | // explicitly set. Either KeyPersistPath or Key must be set. |
| 48 | KeyPersistPath string |
| 49 | |
| 50 | // Prefix applied to all devices (machines) created by the Provisioner, and used |
| 51 | // by the Provisioner to identify machines which it managed. Must be set. |
| 52 | DevicePrefix string |
| 53 | |
| 54 | // configPrefix will be set to the prefix of the latest RegisterFlags call and |
| 55 | // will be then used by various methods to display the full name of a |
| 56 | // misconfigured flag. |
| 57 | configPrefix string |
| 58 | } |
| 59 | |
| 60 | func (c *SharedConfig) check() error { |
| 61 | if c.ProjectId == "" { |
| 62 | return fmt.Errorf("-%sequinix_project_id must be set", c.configPrefix) |
| 63 | } |
| 64 | if c.KeyLabel == "" { |
| 65 | return fmt.Errorf("-%sequinix_ssh_key_label must be set", c.configPrefix) |
| 66 | } |
| 67 | if c.DevicePrefix == "" { |
| 68 | return fmt.Errorf("-%sequinix_device_prefix must be set", c.configPrefix) |
| 69 | } |
| 70 | return nil |
| 71 | } |
| 72 | |
| 73 | func (k *SharedConfig) RegisterFlags(prefix string) { |
| 74 | k.configPrefix = prefix |
| 75 | |
| 76 | flag.StringVar(&k.ProjectId, prefix+"equinix_project_id", "", "Equinix project ID where resources will be managed") |
| 77 | flag.StringVar(&k.KeyLabel, prefix+"equinix_ssh_key_label", "shepherd-FIXME", "Label used to identify managed SSH key in Equinix project") |
| 78 | flag.StringVar(&k.KeyPersistPath, prefix+"ssh_key_path", "shepherd-key.priv", "Local filesystem path to read SSH key from, and save generated key to") |
| 79 | flag.StringVar(&k.DevicePrefix, prefix+"equinix_device_prefix", "shepherd-FIXME-", "Prefix applied to all devices (machines) in Equinix project, used to identify managed machines") |
| 80 | } |
| 81 | |
| 82 | // sshKey returns the SSH key as defined by the Key and KeyPersistPath options, |
| 83 | // loading/generating/persisting it as necessary. |
| 84 | func (c *SharedConfig) sshKey() (ed25519.PrivateKey, error) { |
| 85 | c.muKey.Lock() |
| 86 | defer c.muKey.Unlock() |
| 87 | |
| 88 | if c.Key != nil { |
| 89 | return c.Key, nil |
| 90 | } |
| 91 | if c.KeyPersistPath == "" { |
| 92 | return nil, fmt.Errorf("-%sequinix_ssh_key_path must be set", c.configPrefix) |
| 93 | } |
| 94 | |
| 95 | data, err := os.ReadFile(c.KeyPersistPath) |
| 96 | switch { |
| 97 | case err == nil: |
| 98 | if len(data) != ed25519.PrivateKeySize { |
| 99 | return nil, fmt.Errorf("%s is not a valid ed25519 private key", c.KeyPersistPath) |
| 100 | } |
| 101 | c.Key = data |
| 102 | klog.Infof("Loaded SSH key from %s", c.KeyPersistPath) |
| 103 | return c.Key, nil |
| 104 | case os.IsNotExist(err): |
| 105 | if err := c.sshGenerateUnlocked(); err != nil { |
| 106 | return nil, err |
| 107 | } |
| 108 | if err := os.WriteFile(c.KeyPersistPath, c.Key, 0400); err != nil { |
| 109 | return nil, fmt.Errorf("could not persist key: %w", err) |
| 110 | } |
| 111 | return c.Key, nil |
| 112 | default: |
| 113 | return nil, fmt.Errorf("could not load peristed key: %w", err) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // sshPub returns the SSH public key marshaled for use, based on sshKey. |
| 118 | func (c *SharedConfig) sshPub() (string, error) { |
| 119 | private, err := c.sshKey() |
| 120 | if err != nil { |
| 121 | return "", err |
| 122 | } |
| 123 | // Marshal the public key part in OpenSSH authorized_keys format that will be |
| 124 | // registered with Equinix Metal. |
| 125 | sshpub, err := ssh.NewPublicKey(private.Public()) |
| 126 | if err != nil { |
| 127 | return "", fmt.Errorf("while building SSH public key: %w", err) |
| 128 | } |
| 129 | return string(ssh.MarshalAuthorizedKey(sshpub)), nil |
| 130 | } |
| 131 | |
| 132 | // sshSigner builds an ssh.Signer (for use in SSH connections) based on sshKey. |
| 133 | func (c *SharedConfig) sshSigner() (ssh.Signer, error) { |
| 134 | private, err := c.sshKey() |
| 135 | if err != nil { |
| 136 | return nil, err |
| 137 | } |
| 138 | // Set up the internal ssh.Signer to be later used to initiate SSH |
| 139 | // connections with newly provided hosts. |
| 140 | signer, err := ssh.NewSignerFromKey(private) |
| 141 | if err != nil { |
| 142 | return nil, fmt.Errorf("while building SSH signer: %w", err) |
| 143 | } |
| 144 | return signer, nil |
| 145 | } |
| 146 | |
| 147 | // sshGenerateUnlocked saves a new private key into SharedConfig.Key. |
| 148 | func (c *SharedConfig) sshGenerateUnlocked() error { |
| 149 | if c.Key != nil { |
| 150 | return nil |
| 151 | } |
| 152 | _, priv, err := ed25519.GenerateKey(rand.Reader) |
| 153 | if err != nil { |
| 154 | return fmt.Errorf("while generating SSH key: %w", err) |
| 155 | } |
| 156 | c.Key = priv |
| 157 | return nil |
| 158 | } |
| 159 | |
| 160 | // sshEquinixGet looks up the Equinix key matching SharedConfig.KeyLabel, |
| 161 | // returning its packngo.SSHKey instance. |
| 162 | func (c *SharedConfig) sshEquinix(ctx context.Context, cl ecl.Client) (*packngo.SSHKey, error) { |
| 163 | ks, err := cl.ListSSHKeys(ctx) |
| 164 | if err != nil { |
| 165 | return nil, fmt.Errorf("while listing SSH keys: %w", err) |
| 166 | } |
| 167 | |
| 168 | for _, k := range ks { |
| 169 | if k.Label == c.KeyLabel { |
| 170 | return &k, nil |
| 171 | } |
| 172 | } |
| 173 | return nil, NoSuchKey |
| 174 | } |
| 175 | |
| 176 | // sshEquinixId looks up the Equinix key identified by SharedConfig.KeyLabel, |
| 177 | // returning its Equinix-assigned UUID. |
| 178 | func (c *SharedConfig) sshEquinixId(ctx context.Context, cl ecl.Client) (string, error) { |
| 179 | k, err := c.sshEquinix(ctx, cl) |
| 180 | if err != nil { |
| 181 | return "", err |
| 182 | } |
| 183 | return k.ID, nil |
| 184 | } |
| 185 | |
| 186 | // sshEquinixUpdate makes sure the existing SSH key registered with Equinix |
| 187 | // matches the one from sshPub. |
| 188 | func (c *SharedConfig) sshEquinixUpdate(ctx context.Context, cl ecl.Client, kid string) error { |
| 189 | pub, err := c.sshPub() |
| 190 | if err != nil { |
| 191 | return err |
| 192 | } |
| 193 | _, err = cl.UpdateSSHKey(ctx, kid, &packngo.SSHKeyUpdateRequest{ |
| 194 | Key: &pub, |
| 195 | }) |
| 196 | if err != nil { |
| 197 | return fmt.Errorf("while updating the SSH key: %w", err) |
| 198 | } |
| 199 | return nil |
| 200 | } |
| 201 | |
| 202 | // sshEquinixUpload registers a new SSH key from sshPub. |
| 203 | func (c *SharedConfig) sshEquinixUpload(ctx context.Context, cl ecl.Client) error { |
| 204 | pub, err := c.sshPub() |
| 205 | if err != nil { |
| 206 | return fmt.Errorf("while generating public key: %w", err) |
| 207 | } |
| 208 | _, err = cl.CreateSSHKey(ctx, &packngo.SSHKeyCreateRequest{ |
| 209 | Label: c.KeyLabel, |
| 210 | Key: pub, |
| 211 | ProjectID: c.ProjectId, |
| 212 | }) |
| 213 | if err != nil { |
| 214 | return fmt.Errorf("while creating an SSH key: %w", err) |
| 215 | } |
| 216 | return nil |
| 217 | } |
| 218 | |
| 219 | // SSHEquinixEnsure initializes the locally managed SSH key (from a persistence |
| 220 | // path or explicitly set key) and updates or uploads it to Equinix. The key is |
| 221 | // generated as needed The key is generated as needed |
| 222 | func (c *SharedConfig) SSHEquinixEnsure(ctx context.Context, cl ecl.Client) error { |
| 223 | k, err := c.sshEquinix(ctx, cl) |
| 224 | switch err { |
| 225 | case NoSuchKey: |
| 226 | if err := c.sshEquinixUpload(ctx, cl); err != nil { |
| 227 | return fmt.Errorf("while uploading key: %w", err) |
| 228 | } |
| 229 | return nil |
| 230 | case nil: |
| 231 | if err := c.sshEquinixUpdate(ctx, cl, k.ID); err != nil { |
| 232 | return fmt.Errorf("while updating key: %w", err) |
| 233 | } |
| 234 | return nil |
| 235 | default: |
| 236 | return err |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // managedDevices provides a map of device provider IDs to matching |
| 241 | // packngo.Device instances. It calls Equinix API's ListDevices. The returned |
| 242 | // devices are filtered according to DevicePrefix provided through Opts. The |
| 243 | // returned error value, if not nil, will originate in wrapngo. |
| 244 | func (c *SharedConfig) managedDevices(ctx context.Context, cl ecl.Client) (map[string]packngo.Device, error) { |
| 245 | ds, err := cl.ListDevices(ctx, c.ProjectId) |
| 246 | if err != nil { |
| 247 | return nil, err |
| 248 | } |
| 249 | dm := map[string]packngo.Device{} |
| 250 | for _, d := range ds { |
| 251 | if strings.HasPrefix(d.Hostname, c.DevicePrefix) { |
| 252 | dm[d.ID] = d |
| 253 | } |
| 254 | } |
| 255 | return dm, nil |
| 256 | } |