blob: 6ece4ce07740d4d5701d46a970ac6af55368f806 [file] [log] [blame]
Serge Bazanskicaa12082023-02-16 14:54:04 +01001package manager
2
3import (
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
21var (
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.
29type 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
60func (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
73func (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.
84func (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.
118func (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.
133func (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.
148func (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.
162func (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.
178func (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.
188func (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.
203func (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
222func (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.
244func (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}