type Session struct {
nix.Nix
cmd.Runner
RemoteRunner func(host, user string) (cmd.Runner, error)
vaultLock sync.Mutex
vault *api.Client
vaultSecret *api.KVSecret
keyPath string
}
var _ nix.Nix = (*Session)(nil)
var _ cmd.Runner = (*Session)(nil)
The Session type is created at the start of most dippy commands and is used to access the various external resources that are needed to perform deployment operations.
A Session embeds a nix.Nix, so it also implements nix.Nix and can be used directly to perform Nix operations. Similarly, it also embeds a cmd.Runner and can be used directly as a command runner for commands that need to run locally rather than on a remote host.
func DefaultSession(ctx context.Context, useNom bool) (*Session, error) {
<<DefaultSession-vaultClient>>
<<DefaultSession-signer>>
<<DefaultSession-keyPath>>
<<DefaultSession-hostKeyCallback>>
return &Session{
Nix: nix.New(useNom, keyPath),
Runner: cmd.LocalRunner{},
RemoteRunner: func(host, user string) (cmd.Runner, error) {
return cmd.NewSSHRunner(host, user, signer, hostKeyCallback)
},
vault: vaultClient,
keyPath: keyPath,
}, nil
}
DefaultSession creates a new session with full capabilities. The session will create a new SSH key pair and ask Vault to sign the public key so the certificate can be used to login to the hosts in the lab. Calls to Nix from this session will also use this SSH key when copying derivations to a host. The CLI will pass along the flag for whether nix-output-monitor (nom) should be used for the Nix builder.
//| id: DefaultSession-vaultClient
vaultClient, err := vault.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("creating vault client: %w", err)
}Since Vault needs to sign the SSH certificate, a client for the Vault API needs to be created. Since the session may also need to access Vault for other reasons, this client will also be stored on the session, rather than having another one lazily created later.
The client is created differently depending on whether dippy is running in CI or on a local machine. If SPIFFE is available, as it is in CI, then it will used to login to Vault using JWT login. If not, it's assumed that the Vault CLI has been used to login, and the token it stores will be read and used.
//| id: DefaultSession-signer
cert, privKey, err := vault.GenerateSSHKey(ctx, vaultClient)
if err != nil {
return nil, fmt.Errorf("generating ssh key: %w", err)
}
privKeySigner, err := ssh.NewSignerFromKey(privKey)
if err != nil {
return nil, fmt.Errorf("creating private key signer: %w", err)
}
signer, err := ssh.NewCertSigner(cert, privKeySigner)
if err != nil {
return nil, fmt.Errorf("creating cert signer: %w", err)
}vault.GenerateSSHKey handles generating a new Ed25519 key and signing it with Vault. That key and certificate pair is used to create a new signer, which is used when creating SSH command runners for the session.
//| id: DefaultSession-keyPath
keyPath, err := writeSSHKey(cert, privKey)
if err != nil {
return nil, fmt.Errorf("writing ssh key: %w", err)
}If running commands was all SSH was needed for in dippy, then the key and certificate could just stay in memory. Because Nix needs to use the key as well for copying derivations to hosts, it needs to get written to disk.
func writeSSHKey(cert *ssh.Certificate, privKey crypto.PrivateKey) (string, error) {
keyDir, err := os.MkdirTemp("", "dippy-keys")
if err != nil {
return "", fmt.Errorf("creating temp dir for keys: %w", err)
}
privBlock, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return "", fmt.Errorf("marshalling private key: %w", err)
}
privKeyData := pem.EncodeToMemory(privBlock)
privKeyPath := path.Join(keyDir, "id_ed25519")
if err := os.WriteFile(privKeyPath, privKeyData, 0600); err != nil {
return "", fmt.Errorf("writing private key: %w", err)
}
certData := ssh.MarshalAuthorizedKey(cert)
certPath := path.Join(keyDir, "id_ed25519-cert.pub")
if err := os.WriteFile(certPath, certData, 0600); err != nil {
return "", fmt.Errorf("writing certificate: %w", err)
}
return privKeyPath, nil
}
A new temporary directory is created with a name prefixed with "dippy-keys". The certificate and private key are each encoded into their proper formats and written into this directory. The naming of the files should ensure that OpenSSH can find the certificate matching the private key even when only given the private key path, so that is all that is returned and stored in the session.
//| id: DefaultSession-hostKeyCallback
hostKeyCallback, err := knownhosts.New("/etc/ssh/ssh_known_hosts")
if err != nil {
return nil, fmt.Errorf("reading ssh known hosts: %w", err)
}The Go SSH package uses a callback function to verify host keys, and it provides the ability to construct one from a known_hosts file for OpenSSH. dippy uses this to have consistency with Nix's SSH usage, and because my hosts are all already configured to properly trust other hosts in my lab.
func NewLocalSession(useNom bool) (*Session, error) {
return &Session{
Nix: nix.New(useNom, ""),
Runner: cmd.LocalRunner{},
RemoteRunner: func(host, user string) (cmd.Runner, error) {
return nil, fmt.Errorf("remote runner not supported in this config")
},
}, nil
}
NewLocalSesssion creates a new session for situations that don't need to use SSH to connect to hosts. It skips generating and signing an SSH key, and therefore cannot create new remote command runners. Like with DefaultSession, the CLI will pass the flag for whether to use nom.
func (s *Session) Cleanup() {
if s.keyPath != "" {
os.RemoveAll(path.Dir(s.keyPath))
}
}
Cleanup should be called when the session is no longer being used. If the session generated an SSH key, the directory where those files were stored is deleted.
func (s *Session) GetSecrets(ctx context.Context) (map[string]any, error) {
s.vaultLock.Lock()
defer s.vaultLock.Unlock()
if s.vaultSecret == nil {
client, err := s.getVaultClient(ctx)
if err != nil {
return nil, fmt.Errorf("getting vault client: %w", err)
}
s.vaultSecret, err = client.KVv2("kv").Get(ctx, "prod/repos/nix-config")
if err != nil {
return nil, fmt.Errorf("getting nix-config kv secret from vault: %w", err)
}
}
return s.vaultSecret.Data, nil
}
GetSecrets reads the data from the prod/repos/nix-config secret in Vault. The data is fetched lazily when it is first needed, and from then on it is cached and can be accessed without talking to Vault. To maintain thread-safety, a mutex is used to protect this cached secret data as well as the Vault client.
func (s *Session) getVaultClient(ctx context.Context) (*api.Client, error) {
if s.vault == nil {
var err error
s.vault, err = vault.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("creating vault client to read secret: %w", err)
}
}
return s.vault, nil
}
getVaultClient handles the lazy initialization of the Vault client. It should only be called while the vault lock is held.
func (s *Session) GetSecret(ctx context.Context, key string) (string, error) {
data, err := s.GetSecrets(ctx)
if err != nil {
return "", err
}
return data[key].(string), nil
}
GetSecret reads a single value from the prod/repos/nix-config secret in Vault. It delegates to GetSecrets, but then returns only the single value of interest.
func (s *Session) VaultToken(ctx context.Context) (string, error) {
s.vaultLock.Lock()
defer s.vaultLock.Unlock()
c, err := s.getVaultClient(ctx)
if err != nil {
return "", err
}
return c.Token(), nil
}
VaultToken provides access to the Vault token being used by the session's Vault client. This is used for making infrastructure changes, as it's required for setting up Vault policies, entities, and other things.
func (s *Session) LoginToAttic(ctx context.Context) error {
token, err := s.GetSecret(ctx, "attic_token")
if err != nil {
return fmt.Errorf("reading attic token: %w", err)
}
if err := cmd.Run(ctx, s, "attic", "login", "homelab-dippy", "https://attic.midna.dev", token); err != nil {
return fmt.Errorf("running attic login: %w", err)
}
return nil
}
LoginToAttic ensures that the attic client is logged in and can be used to push built derivations to the cache. It fetches dippy's attic token from Vault and then runs the "attic login" command with it. The name "homelab-dippy" is used to avoid clobbering another token that might already be set for the homelab server.
//| file: packages/dippy/deploy/session.go package deploy import ( "context" "crypto" "encoding/pem" "fmt" "os" "path" "sync" "git.midna.dev/mjm/nix-config/packages/dippy/cmd" "git.midna.dev/mjm/nix-config/packages/dippy/nix" "git.midna.dev/mjm/nix-config/packages/dippy/vault" "github.com/hashicorp/vault/api" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" ) <<Session>> <<DefaultSession>> <<writeSSHKey>> <<NewLocalSession>> <<Session.Cleanup>> <<Session.GetSecrets>> <<Session.getVaultClient>> <<Session.GetSecret>> <<Session.VaultToken>> <<Session.LoginToAttic>>
text/gemini;lang=en-USThis content has been proxied by September (UNKNO).