143 lines
5.7 KiB
Go
143 lines
5.7 KiB
Go
package main
|
|
|
|
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
|
|
//
|
|
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
|
|
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
|
|
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
|
|
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
|
|
// 0266: no other module writes there), and points every session's git at it through the managed settings'
|
|
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
|
|
// token is printed, typed on a command line or put in an environment a process listing shows.
|
|
//
|
|
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
|
|
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
|
|
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
|
|
// been asked yet — is not placed, and what was placed before is taken back.
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
|
const (
|
|
ForgeProvision = "gitea-read"
|
|
forgeSecret = "gitea-read.secret"
|
|
)
|
|
|
|
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
|
|
|
|
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
|
|
// mesh's own.
|
|
func (p Paths) forgeCredentials() string {
|
|
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
|
|
}
|
|
|
|
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
|
|
// and nothing else. Anything else is refused, said without the value.
|
|
func ForgeCredentialLines(raw string) (string, error) {
|
|
var lines []string
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
u, err := url.Parse(line)
|
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
|
|
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
|
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
|
|
"(the forge has not issued it yet, or something else was given)")
|
|
}
|
|
if pw, ok := u.User.Password(); !ok || pw == "" {
|
|
return "", errors.New("a forge credential line names no token")
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
if len(lines) == 0 {
|
|
return "", errors.New("the forge's read credential is empty")
|
|
}
|
|
return strings.Join(lines, "\n") + "\n", nil
|
|
}
|
|
|
|
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
|
|
func ForgeCredentialHeld(p Paths) bool {
|
|
raw, err := os.ReadFile(p.forgeSecret())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, err = ForgeCredentialLines(string(raw))
|
|
return err == nil
|
|
}
|
|
|
|
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
|
|
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
|
|
func PlaceForgeCredential(p Paths) (string, error) {
|
|
path := p.forgeCredentials()
|
|
raw, err := os.ReadFile(p.forgeSecret())
|
|
var lines string
|
|
var why error
|
|
switch {
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
why = errors.New("this machine holds no forge read credential")
|
|
case err != nil:
|
|
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
|
|
default:
|
|
lines, why = ForgeCredentialLines(string(raw))
|
|
}
|
|
if why != nil {
|
|
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
|
return "", nil
|
|
}
|
|
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
|
|
}
|
|
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
|
|
}
|
|
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
|
|
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
|
|
return "", nil
|
|
}
|
|
}
|
|
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
|
|
}
|
|
tmp := path + ".mesh-tmp"
|
|
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
|
|
return "", fmt.Errorf("the forge credential could not be written: %w", err)
|
|
}
|
|
if err := fsys.Chmod(tmp, 0o600); err != nil {
|
|
_ = fsys.Remove(tmp)
|
|
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
|
|
}
|
|
if err := fsys.Rename(tmp, path); err != nil {
|
|
_ = fsys.Remove(tmp)
|
|
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
|
|
}
|
|
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
|
|
}
|
|
|
|
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
|
|
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
|
|
func forgeEnv(managed map[string]any, credentials string) {
|
|
if credentials == "" {
|
|
return
|
|
}
|
|
env := map[string]any{}
|
|
if held, ok := managed["env"].(map[string]any); ok {
|
|
for k, v := range held {
|
|
env[k] = v
|
|
}
|
|
}
|
|
env["GIT_CONFIG_COUNT"] = "1"
|
|
env["GIT_CONFIG_KEY_0"] = "credential.helper"
|
|
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
|
|
managed["env"] = env
|
|
}
|