Merge pull request 'Feature agents-write-git, step 2: git over SSH with a key made on each machine (issue 955)' (#340) from feat/agents-write-git-ssh into main
This commit was merged in pull request #340.
This commit is contained in:
@@ -221,3 +221,15 @@ whenever the tools' account is set up, with the administrator's login (`GiteaAdm
|
|||||||
account is no repository's administrator, so no tool can. A rule found keeps the statuses it requires; a repository
|
account is no repository's administrator, so no tool can. A rule found keeps the statuses it requires; a repository
|
||||||
with none is given one that requires none. `judgeProtectionChange` refuses, for `main`, a change that enables
|
with none is given one that requires none. `judgeProtectionChange` refuses, for `main`, a change that enables
|
||||||
push, drops the merge whitelist or widens it past that account.
|
push, drops the merge whitelist or widens it past that account.
|
||||||
|
|
||||||
|
## The forge account for git over ssh (novox/hq ADR 0399, ADR 0400, issue 955)
|
||||||
|
|
||||||
|
Gitea provides `forge-account`, and `cmd/forge-account-provider` answers its consumers (the `ssh-client` of each
|
||||||
|
machine). The provision has no credential: a public key is no secret. For a machine whose ssh-client has reported its
|
||||||
|
key in the state `keys` (read, `ssh-client.keys`), the provider makes the account `agent-<node>` in the agents team
|
||||||
|
(`internal/forgeaccount`, the code `cmd/agent-token` shares), registers the key under the title `mesh:<node>.<account>`,
|
||||||
|
and serves the entry values `user`, `ssh_host` (the forge machine's mesh name, from the fact `forge-host`), `ssh_port`,
|
||||||
|
`known_host` (read from the forge's own sshd with `ssh-keyscan`), `ssh_base` and `https_base`. A machine that has
|
||||||
|
reported no key gets no entry, and its client says it waits. Keys titled `mesh:` that are no longer wanted are removed,
|
||||||
|
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
|
||||||
|
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/nacl/box"
|
"golang.org/x/crypto/nacl/box"
|
||||||
|
|
||||||
|
"gitea/internal/forgeaccount"
|
||||||
"gitea/internal/forgehttp"
|
"gitea/internal/forgehttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ const (
|
|||||||
// TokenName is the token's name in the agent account's list, found and deleted by it at the next mint.
|
// TokenName is the token's name in the agent account's list, found and deleted by it at the next mint.
|
||||||
TokenName = "mesh-agent-read"
|
TokenName = "mesh-agent-read"
|
||||||
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
|
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
|
||||||
AccountPrefix = "agent-"
|
AccountPrefix = forgeaccount.AccountPrefix
|
||||||
// vaultModule is the vault's module: its account is `<node>.mesh-vault` (ADR 0259 §8's runs-as).
|
// vaultModule is the vault's module: its account is `<node>.mesh-vault` (ADR 0259 §8's runs-as).
|
||||||
vaultModule = "mesh-vault"
|
vaultModule = "mesh-vault"
|
||||||
// deadline bounds one call, inside the vault's ask.
|
// deadline bounds one call, inside the vault's ask.
|
||||||
@@ -43,23 +43,13 @@ const (
|
|||||||
// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2).
|
// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2).
|
||||||
var TokenScopes = []string{"write:repository", "write:issue"}
|
var TokenScopes = []string{"write:repository", "write:issue"}
|
||||||
|
|
||||||
|
// The agents team and its shape are the forgeaccount package's (ADR 0398 §1).
|
||||||
const (
|
const (
|
||||||
// TeamOrg is the organisation whose repositories are the mesh's.
|
TeamOrg = forgeaccount.TeamOrg
|
||||||
TeamOrg = "novox"
|
TeamName = forgeaccount.TeamName
|
||||||
// TeamName is the one team every agent's account is in (ADR 0398 §1).
|
|
||||||
TeamName = "agents"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the
|
var nodeName = forgeaccount.NodeName
|
||||||
// organisation and no more, the shape of the forge tools' team (tools-account.ts).
|
|
||||||
func teamShape() map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
"name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false,
|
|
||||||
"units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
|
||||||
|
|
||||||
// fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this
|
// fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this
|
||||||
// tool would hold every agent's credential.
|
// tool would hold every agent's credential.
|
||||||
@@ -205,11 +195,11 @@ func (m *Minter) Answer(ask Ask) (Answer, error) {
|
|||||||
skip(h, "the forge names no origin it is reached at from that machine")
|
skip(h, "the forge names no origin it is reached at from that machine")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
account := AccountPrefix + h.Node
|
account := forgeaccount.ForgeUser(h.Node, "machine")
|
||||||
if err := m.ensureAccount(ctx, account); err != nil {
|
if err := m.accounts().EnsureAccount(ctx, account); err != nil {
|
||||||
return Answer{}, err
|
return Answer{}, err
|
||||||
}
|
}
|
||||||
if err := m.ensureInTeam(ctx, account); err != nil {
|
if err := m.accounts().EnsureInTeam(ctx, account); err != nil {
|
||||||
return Answer{}, err
|
return Answer{}, err
|
||||||
}
|
}
|
||||||
token, err := m.mint(ctx, account)
|
token, err := m.mint(ctx, account)
|
||||||
@@ -274,91 +264,6 @@ func credentialLines(account, token string, origins []string) (string, error) {
|
|||||||
return strings.Join(lines, "\n") + "\n", nil
|
return strings.Join(lines, "\n") + "\n", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// gitea's account as the admin reads it.
|
|
||||||
type forgeUser struct {
|
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
ProhibitLogin bool `json:"prohibit_login"`
|
|
||||||
Restricted bool `json:"restricted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never
|
|
||||||
// an administrator, active, able to use its token. Its team is ensureInTeam's.
|
|
||||||
func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
|
||||||
var u forgeUser
|
|
||||||
err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u)
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, forgehttp.ErrNotFound):
|
|
||||||
password := m.password()
|
|
||||||
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
|
||||||
"username": account, "email": account + "@noreply.localhost", "password": password,
|
|
||||||
"must_change_password": false, "visibility": "private",
|
|
||||||
}, nil)
|
|
||||||
password = ""
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("the account %s could not be made: %w", account, err)
|
|
||||||
}
|
|
||||||
m.say("made the forge account %s for that machine's agent, in no team yet", account)
|
|
||||||
return nil
|
|
||||||
case err != nil:
|
|
||||||
return fmt.Errorf("the account %s could not be read: %w", account, err)
|
|
||||||
}
|
|
||||||
if u.IsAdmin || !u.Active || u.ProhibitLogin {
|
|
||||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{
|
|
||||||
"login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false,
|
|
||||||
}, nil); err != nil {
|
|
||||||
return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err)
|
|
||||||
}
|
|
||||||
m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again",
|
|
||||||
account, u.IsAdmin, u.Active, u.ProhibitLogin)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the
|
|
||||||
// account to it; an account already in it stays (ADR 0398 §1, issue 948).
|
|
||||||
func (m *Minter) ensureInTeam(ctx context.Context, account string) error {
|
|
||||||
var id int64
|
|
||||||
for page := 1; page <= 20 && id == 0; page++ {
|
|
||||||
var teams []struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil {
|
|
||||||
return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
|
||||||
}
|
|
||||||
for _, t := range teams {
|
|
||||||
if t.Name == TeamName {
|
|
||||||
id = t.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(teams) < 50 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if id != 0 {
|
|
||||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil {
|
|
||||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var made struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
}
|
|
||||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil {
|
|
||||||
return fmt.Errorf("the team %s could not be made: %w", TeamName, err)
|
|
||||||
}
|
|
||||||
id = made.ID
|
|
||||||
m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg)
|
|
||||||
}
|
|
||||||
if id == 0 {
|
|
||||||
return fmt.Errorf("the forge answered no id for the team %s", TeamName)
|
|
||||||
}
|
|
||||||
if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil {
|
|
||||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type forgeToken struct {
|
type forgeToken struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -392,13 +297,9 @@ func (m *Minter) mint(ctx context.Context, account string) (string, error) {
|
|||||||
return strings.TrimSpace(made.SHA1), nil
|
return strings.TrimSpace(made.SHA1), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Minter) password() string {
|
// accounts is the forge's accounts as this minter reaches them (ADR 0400 §3: one place says what an agent's account is).
|
||||||
if m.Password != nil {
|
func (m *Minter) accounts() *forgeaccount.Accounts {
|
||||||
return m.Password()
|
return &forgeaccount.Accounts{Forge: m.Forge, Password: m.Password, Log: m.Log}
|
||||||
}
|
|
||||||
b := make([]byte, 24)
|
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Minter) say(format string, a ...any) {
|
func (m *Minter) say(format string, a ...any) {
|
||||||
@@ -408,3 +309,6 @@ func (m *Minter) say(format string, a ...any) {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forgeUser is gitea's account as the admin reads it (the forgeaccount package's).
|
||||||
|
type forgeUser = forgeaccount.User
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The provider whole, against a real bus (issue 955 F5): a JetStream server is linked into the test and started for
|
||||||
|
// it alone, and the provider is this test binary started again as the bundle, speaking MCP on its standard streams
|
||||||
|
// to a runtime played here, which does for the bundle's state what the tool runner does: each state verb and each
|
||||||
|
// watch on the bucket the controller makes from the manifest. The forge is a fake (an httptest server), and the
|
||||||
|
// forge's own sshd is a program named ssh-keyscan on the bundle's PATH.
|
||||||
|
//
|
||||||
|
// It starts a server and a child process, so it is left to the merge check: run the unit tests with -short.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats-server/v2/server"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bundleVar = "MESH_FORGE_ACCOUNT_TEST_BUNDLE"
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv(bundleVar) == "1" {
|
||||||
|
main()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
type runtime struct {
|
||||||
|
t *testing.T
|
||||||
|
js jetstream.JetStream
|
||||||
|
kvs map[string]jetstream.KeyValue
|
||||||
|
in io.WriteCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
seq int
|
||||||
|
ctx context.Context
|
||||||
|
// answers to what the runtime asked the bundle, by id.
|
||||||
|
wait map[string]chan json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func startBus(t *testing.T) (*runtime, context.CancelFunc) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true,
|
||||||
|
StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go s.Start()
|
||||||
|
if !s.ReadyForConnections(30 * time.Second) {
|
||||||
|
s.Shutdown()
|
||||||
|
t.Fatal("a bus for this test did not come up within 30s")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||||
|
nc, err := nats.Connect(s.ClientURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
js, err := jetstream.New(nc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
rt := &runtime{t: t, js: js, kvs: map[string]jetstream.KeyValue{}, ctx: ctx, wait: map[string]chan json.RawMessage{}}
|
||||||
|
// The controller's job: a bucket per state the module declares (bound) or reads (the others).
|
||||||
|
for state, bucket := range map[string]string{"bound": "gitea_bound", "ssh-client.keys": "ssh-client_keys",
|
||||||
|
"mesh-controller.assignments": "mesh-controller_assignments"} {
|
||||||
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rt.kvs[state] = kv
|
||||||
|
}
|
||||||
|
return rt, cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpc struct {
|
||||||
|
ID json.RawMessage `json:"id,omitempty"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) say(m map[string]any) {
|
||||||
|
m["jsonrpc"] = "2.0"
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, _ = r.in.Write(append(raw, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) start(env []string) {
|
||||||
|
r.t.Helper()
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||||
|
cmd.Env = append(os.Environ(), append(env, bundleVar+"=1")...)
|
||||||
|
in, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.Stderr = testWriter{r.t}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.in = in
|
||||||
|
go func() {
|
||||||
|
sc := bufio.NewScanner(out)
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||||
|
for sc.Scan() {
|
||||||
|
var m rpc
|
||||||
|
if json.Unmarshal(sc.Bytes(), &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Method == "" {
|
||||||
|
var id string
|
||||||
|
_ = json.Unmarshal(m.ID, &id)
|
||||||
|
r.mu.Lock()
|
||||||
|
ch := r.wait[id]
|
||||||
|
delete(r.wait, id)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- m.Result
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go r.answer(m)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
r.t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() })
|
||||||
|
}
|
||||||
|
|
||||||
|
type testWriter struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (w testWriter) Write(p []byte) (int, error) { w.t.Logf("bundle: %s", p); return len(p), nil }
|
||||||
|
|
||||||
|
// push tells the bundle one change of a state it watches, and waits until it has taken it.
|
||||||
|
func (r *runtime) push(watch string, c map[string]any) {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.seq++
|
||||||
|
id := "runtime-" + strconv.Itoa(r.seq)
|
||||||
|
ch := make(chan json.RawMessage, 1)
|
||||||
|
r.wait[id] = ch
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.say(map[string]any{"id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, "change": c}})
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
r.t.Errorf("the bundle did not take a change of %v in 20 s", c["key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) answer(m rpc) {
|
||||||
|
var p struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(m.Params, &p)
|
||||||
|
result, err := func() (any, error) {
|
||||||
|
kv := r.kvs[p.State]
|
||||||
|
if kv == nil {
|
||||||
|
return nil, fmt.Errorf("the state %q is not one this bundle declares or reads", p.State)
|
||||||
|
}
|
||||||
|
switch m.Method {
|
||||||
|
case "mesh/state.get":
|
||||||
|
e, err := kv.Get(r.ctx, p.Key)
|
||||||
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"key": p.Key, "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil
|
||||||
|
case "mesh/state.put":
|
||||||
|
rev, err := kv.Put(r.ctx, p.Key, p.Value)
|
||||||
|
return map[string]any{"revision": rev}, err
|
||||||
|
case "mesh/state.delete":
|
||||||
|
err := kv.Delete(r.ctx, p.Key)
|
||||||
|
return map[string]any{}, err
|
||||||
|
case "mesh/state.watch":
|
||||||
|
w, err := kv.WatchAll(r.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := strings.Trim(string(m.ID), `"`)
|
||||||
|
for {
|
||||||
|
var e jetstream.KeyValueEntry
|
||||||
|
select {
|
||||||
|
case e = <-w.Updates():
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
return nil, r.ctx.Err()
|
||||||
|
}
|
||||||
|
if e == nil { // the current values are all handed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e.Operation() == jetstream.KeyValuePut {
|
||||||
|
r.push(id, map[string]any{"state": p.State, "key": e.Key(), "op": "put", "revision": e.Revision(),
|
||||||
|
"current": true, "value": json.RawMessage(e.Value())})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for e := range w.Updates() {
|
||||||
|
change := map[string]any{"state": p.State, "key": e.Key(), "op": "put", "revision": e.Revision(), "current": false,
|
||||||
|
"value": json.RawMessage(e.Value())}
|
||||||
|
if e.Operation() != jetstream.KeyValuePut {
|
||||||
|
change["op"] = "delete"
|
||||||
|
delete(change, "value")
|
||||||
|
}
|
||||||
|
r.push(id, change)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("the runtime offers no %s to this bundle", m.Method)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
r.say(map[string]any{"id": m.ID, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.say(map[string]any{"id": m.ID, "result": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) put(state, key string, v any) {
|
||||||
|
r.t.Helper()
|
||||||
|
raw, _ := json.Marshal(v)
|
||||||
|
if _, err := r.kvs[state].Put(r.ctx, key, raw); err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtime) boundEntry(key string) (provider.Entry, bool) {
|
||||||
|
e, err := r.kvs["bound"].Get(r.ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return provider.Entry{}, false
|
||||||
|
}
|
||||||
|
var out provider.Entry
|
||||||
|
_ = json.Unmarshal(e.Value(), &out)
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventually(t *testing.T, what string, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for deadline := time.Now().Add(40 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||||
|
if ok() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("not within 40 s: %s", what)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheProviderWholeAnswersAMachineThatReportedItsKey(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("starts a bus and a process: left to the merge check")
|
||||||
|
}
|
||||||
|
forge := newForge()
|
||||||
|
srv := httptest.NewServer(forge)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := filepath.Join(dir, "bin")
|
||||||
|
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := map[string]string{
|
||||||
|
"pw": "x\n",
|
||||||
|
"host": "novox.internal\n",
|
||||||
|
"origins": "https://git.novox.be\n",
|
||||||
|
"bin/ssh-keyscan": "#!/bin/sh\necho '[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY'\n",
|
||||||
|
}
|
||||||
|
for name, c := range files {
|
||||||
|
mode := os.FileMode(0o600)
|
||||||
|
if strings.HasPrefix(name, "bin/") {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(c), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rt, cancel := startBus(t)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
rt.put("mesh-controller.assignments", "g14.ssh-client", map[string]any{"node": "g14", "module": "ssh-client",
|
||||||
|
"provisioning": map[string]any{"provisions": []map[string]any{{"provision": "forge-account", "provider": "novox",
|
||||||
|
"provider-module": "gitea", "as": "mesh_g14_ssh_client"}}}})
|
||||||
|
rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine",
|
||||||
|
"public": pubA + " nox@g14 (mesh)", "fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"})
|
||||||
|
rt.start([]string{"MESH_NODE=novox", "MESH_MODULE=gitea", "MESH_SERVED_MODULE=gitea",
|
||||||
|
"PATH=" + bin + ":" + os.Getenv("PATH"),
|
||||||
|
"MESH_GITEA_URL=" + srv.URL, "MESH_GITEA_ADMIN_USER=mesh-admin",
|
||||||
|
"MESH_GITEA_ADMIN_PASSWORD_FILE=" + filepath.Join(dir, "pw"),
|
||||||
|
"MESH_GITEA_ORIGINS_FILE=" + filepath.Join(dir, "origins"),
|
||||||
|
"MESH_FORGE_HOST_FILE=" + filepath.Join(dir, "host"), "MESH_GITEA_SSH_PORT=222"})
|
||||||
|
|
||||||
|
var e provider.Entry
|
||||||
|
eventually(t, "the bound entry of g14's ssh-client", func() bool {
|
||||||
|
var ok bool
|
||||||
|
e, ok = rt.boundEntry("g14.ssh-client.forge-account")
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
if e.Values["user"] != "agent-g14" || e.Values["ssh_host"] != "novox.internal" ||
|
||||||
|
e.Values["known_host"] != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" ||
|
||||||
|
e.Values["ssh_base"] != "ssh://git@novox.internal:222/" || e.Values["https_base"] != "https://git.novox.be/" {
|
||||||
|
t.Fatalf("values = %v", e.Values)
|
||||||
|
}
|
||||||
|
if got := forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" {
|
||||||
|
t.Fatalf("registered keys = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key that changes is answered without waiting for the next pass.
|
||||||
|
rt.put("ssh-client.keys", "g14.nox", map[string]string{"node": "g14", "account": "nox", "kind": "machine",
|
||||||
|
"public": pubB + " nox@g14 (mesh)", "fingerprint": "SHA256:y", "at": "2026-10-16T11:00:00Z"})
|
||||||
|
eventually(t, "the new key registered and the old one gone", func() bool {
|
||||||
|
forge.mu.Lock()
|
||||||
|
defer forge.mu.Unlock()
|
||||||
|
k := forge.held["agent-g14"]
|
||||||
|
return len(k) == 1 && strings.HasPrefix(k[0]["key"].(string), pubB)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// forge-account-provider: gitea as the provider of `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955). A process
|
||||||
|
// the node's runtime launches and speaks MCP to over stdio, beside the module's other bundles.
|
||||||
|
//
|
||||||
|
// For each machine whose ssh-client binds the provision and has reported its public key (ssh-client's state `keys`),
|
||||||
|
// it makes the machine's agent a forge account of its own (agent-<node>, in the agents team), registers the key for
|
||||||
|
// it, and serves where and as whom to reach the forge over ssh: the user, the forge machine's mesh name and port, its
|
||||||
|
// host key and the two url roots. A public key is no secret, so the provision has no credential.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; it says what it says on stderr, and never the admin's password.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||||
|
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func say(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[forge-account] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execRunner runs a program on this machine.
|
||||||
|
type execRunner struct{}
|
||||||
|
|
||||||
|
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, name, args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateKeys is ssh-client's state `keys` over the runtime (the manifest's `reads` grants it).
|
||||||
|
type stateKeys struct{}
|
||||||
|
|
||||||
|
func (stateKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||||
|
return stdio.State("ssh-client.keys").Watch("", func(c stdio.StateChange) error {
|
||||||
|
handle(c.Key, c.Op, c.Value, c.Current)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// configFromEnv is the service's environment as the manifest hands it, naming what is missing.
|
||||||
|
func configFromEnv(getenv func(string) string) (Config, error) {
|
||||||
|
port, perr := strconv.Atoi(getenv("MESH_GITEA_SSH_PORT"))
|
||||||
|
cfg := Config{
|
||||||
|
Node: getenv("MESH_NODE"),
|
||||||
|
HostFile: getenv("MESH_FORGE_HOST_FILE"),
|
||||||
|
OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"),
|
||||||
|
Port: port,
|
||||||
|
Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"),
|
||||||
|
PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second},
|
||||||
|
Runner: execRunner{},
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for _, kv := range [][2]string{{"MESH_NODE", cfg.Node}, {"MESH_FORGE_HOST_FILE", cfg.HostFile},
|
||||||
|
{"MESH_GITEA_ORIGINS_FILE", cfg.OriginsFile}, {"MESH_GITEA_URL", cfg.Forge.URL}, {"MESH_GITEA_ADMIN_USER", cfg.Forge.User},
|
||||||
|
{"MESH_GITEA_ADMIN_PASSWORD_FILE", cfg.Forge.PasswordFile}} {
|
||||||
|
if kv[1] == "" {
|
||||||
|
missing = append(missing, kv[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perr != nil || port <= 0 {
|
||||||
|
missing = append(missing, "MESH_GITEA_SSH_PORT")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return Config{}, fmt.Errorf("the forge-account provider cannot start: %s not set by the manifest", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := configFromEnv(os.Getenv)
|
||||||
|
if err != nil {
|
||||||
|
// Said, not fatal: the runtime would restart a process that cannot do better.
|
||||||
|
say("%v; providing nothing", err)
|
||||||
|
if err := stdio.Serve("", nil); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Log = say
|
||||||
|
svc := NewService(cfg)
|
||||||
|
p := svc.Provider(provider.StateBus(Module))
|
||||||
|
ctx := context.Background()
|
||||||
|
// A pass answers every consumer; one at a time, and one per change of a machine's key.
|
||||||
|
var passing sync.Mutex
|
||||||
|
pass := func() {
|
||||||
|
passing.Lock()
|
||||||
|
defer passing.Unlock()
|
||||||
|
if _, err := p.Pass(ctx); err != nil {
|
||||||
|
say("a pass failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc.OnKeys = func(string) { go pass() }
|
||||||
|
go func() {
|
||||||
|
if err := svc.WatchKeys(ctx, stateKeys{}); err != nil {
|
||||||
|
say("the machines' keys are not watched: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
say("watching the machines' keys")
|
||||||
|
pass()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
if err := p.Run(ctx); err != nil {
|
||||||
|
say("the provider stopped: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := stdio.Serve("", nil); err != nil {
|
||||||
|
say("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigFromEnvNamesWhatIsMissing(t *testing.T) {
|
||||||
|
env := map[string]string{"MESH_NODE": "novox", "MESH_FORGE_HOST_FILE": "/h", "MESH_GITEA_ORIGINS_FILE": "/o",
|
||||||
|
"MESH_GITEA_URL": "http://127.0.0.1:3000/", "MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||||
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/p", "MESH_GITEA_SSH_PORT": "222"}
|
||||||
|
cfg, err := configFromEnv(func(k string) string { return env[k] })
|
||||||
|
if err != nil || cfg.Port != 222 || cfg.Forge.URL != "http://127.0.0.1:3000" {
|
||||||
|
t.Fatalf("%+v %v", cfg, err)
|
||||||
|
}
|
||||||
|
delete(env, "MESH_FORGE_HOST_FILE")
|
||||||
|
env["MESH_GITEA_SSH_PORT"] = "x"
|
||||||
|
_, err = configFromEnv(func(k string) string { return env[k] })
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "MESH_FORGE_HOST_FILE") || !strings.Contains(err.Error(), "MESH_GITEA_SSH_PORT") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||||
|
|
||||||
|
"gitea/internal/forgeaccount"
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProvisionName is what gitea provides here, as its manifest says (novox/hq ADR 0400).
|
||||||
|
const ProvisionName = "forge-account"
|
||||||
|
|
||||||
|
// Module is gitea's name in the mesh.
|
||||||
|
const Module = "gitea"
|
||||||
|
|
||||||
|
// Runner runs a program; a test fakes it.
|
||||||
|
type Runner interface {
|
||||||
|
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeySource is the machines' public keys as the bus keeps them (ssh-client's state `keys`): the whole current set,
|
||||||
|
// then each change. handle is told the key, the operation ("put" or "delete"), the value and whether it was there
|
||||||
|
// when the watch began.
|
||||||
|
type KeySource interface {
|
||||||
|
Watch(handle func(key, op string, value []byte, current bool)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is what the service needs from its environment.
|
||||||
|
type Config struct {
|
||||||
|
// Node is the forge's machine.
|
||||||
|
Node string
|
||||||
|
// HostFile holds the forge machine's own mesh name (a fact of the machine), OriginsFile the forge's public
|
||||||
|
// origins, one per line.
|
||||||
|
HostFile, OriginsFile string
|
||||||
|
// Port is where the forge's sshd is published on the mesh.
|
||||||
|
Port int
|
||||||
|
Forge forgehttp.Client
|
||||||
|
Runner Runner
|
||||||
|
Log func(format string, a ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
|
||||||
|
// machine whose ssh-client reported a public key it makes the machine's agent a forge account, registers the key
|
||||||
|
// for it, and serves where and as whom to reach the forge over ssh. A key is no secret: the offer has no credential.
|
||||||
|
type Service struct {
|
||||||
|
cfg Config
|
||||||
|
accounts *forgeaccount.Accounts
|
||||||
|
|
||||||
|
// OnKeys is told the node whose key changed, so its consumer is answered without waiting for the next pass.
|
||||||
|
OnKeys func(node string)
|
||||||
|
// Pause waits between asks of a watch the runtime does not answer yet.
|
||||||
|
Pause func(time.Duration)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
keys map[string]machineKey
|
||||||
|
loaded bool // the machines' keys have been read: until then a node with none has not reported none
|
||||||
|
known string // the forge's host key line, as the machines write it into known_hosts
|
||||||
|
|
||||||
|
bound provider.Bus // the bus the entries are written to (set by Provider)
|
||||||
|
|
||||||
|
making sync.Mutex // one Make at a time: they register keys, and two at once would race each other
|
||||||
|
}
|
||||||
|
|
||||||
|
type machineKey struct {
|
||||||
|
Node, Account, Kind, Public string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService is the service over its environment.
|
||||||
|
func NewService(cfg Config) *Service {
|
||||||
|
if cfg.Log == nil {
|
||||||
|
cfg.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[gitea forge-account] "+f+"\n", a...) }
|
||||||
|
}
|
||||||
|
return &Service{cfg: cfg, keys: map[string]machineKey{}, Pause: time.Sleep,
|
||||||
|
accounts: &forgeaccount.Accounts{Forge: cfg.Forge, Log: cfg.Log}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is the provider helper answering the consumers over bus. Every consumer is made again every ten minutes,
|
||||||
|
// which puts back an account, its team or a key changed by hand.
|
||||||
|
func (s *Service) Provider(bus provider.Bus) *provider.Provider {
|
||||||
|
vb := &valuesBus{Bus: bus, s: s, written: map[string][]byte{}}
|
||||||
|
s.bound = vb
|
||||||
|
return provider.New(provider.Options{
|
||||||
|
Node: s.cfg.Node, Module: Module,
|
||||||
|
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}},
|
||||||
|
Bus: vb,
|
||||||
|
Make: s.Make, Gone: s.Gone,
|
||||||
|
Every: 10 * time.Minute,
|
||||||
|
Logf: s.cfg.Log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNoKey is a consumer whose machine has reported no key yet: nothing is made, and the client says it waits.
|
||||||
|
type errNoKey struct{ node string }
|
||||||
|
|
||||||
|
func (e errNoKey) Error() string {
|
||||||
|
return "no key from " + e.node + " yet: its ssh-client has not reported a machine key, so there is no account " +
|
||||||
|
"and no entry to make"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make brings one consumer's account, its team membership and its keys into being, as the machine reported them. It
|
||||||
|
// leaves what is as asked alone, so it is asked again freely.
|
||||||
|
func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
||||||
|
s.making.Lock()
|
||||||
|
defer s.making.Unlock()
|
||||||
|
node := c.Entry.Consumer
|
||||||
|
user := forgeaccount.ForgeUser(node, "machine")
|
||||||
|
if user == "" {
|
||||||
|
return fmt.Errorf("%q is not a machine's name an account can be named for", node)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
loaded := s.loaded
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !loaded {
|
||||||
|
// After a restart the first pass can come before the keys arrive: nothing is removed or deleted on that.
|
||||||
|
return errors.New("the machines' keys are not read yet, so nothing is made, removed or deleted")
|
||||||
|
}
|
||||||
|
have := s.keysOf(node)
|
||||||
|
if len(have) == 0 {
|
||||||
|
// A key the machine no longer reports is no longer wanted (ADR 0400 §2): the mesh's keys go, the operator's
|
||||||
|
// stay, and the entry that served the machine goes with them.
|
||||||
|
if err := s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+node+"."); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.bound != nil {
|
||||||
|
if err := s.bound.Delete(ctx, provider.BucketOf(Module), c.Entry.Key()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errNoKey{node}
|
||||||
|
}
|
||||||
|
if _, err := s.hostName(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
known, err := s.scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.accounts.EnsureAccount(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.accounts.EnsureInTeam(ctx, user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var wanted []forgeaccount.WantedKey
|
||||||
|
for _, k := range have {
|
||||||
|
wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public})
|
||||||
|
}
|
||||||
|
if err := s.accounts.EnsureKeys(ctx, user, wanted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.known = known
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gone removes the keys the mesh registered for a machine that no longer binds the forge. The account stays: it may
|
||||||
|
// hold what the machine's agent wrote, and the keys of the operator's own that were added by hand are not touched
|
||||||
|
// (ADR 0399 §2).
|
||||||
|
func (s *Service) Gone(ctx context.Context, e provider.Entry) error {
|
||||||
|
s.making.Lock()
|
||||||
|
defer s.making.Unlock()
|
||||||
|
user := forgeaccount.ForgeUser(e.Consumer, "machine")
|
||||||
|
if user == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+e.Consumer+".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyChanged takes in one machine key as the bus keeps it, and tells OnKeys its node.
|
||||||
|
func (s *Service) KeyChanged(key string, value []byte) {
|
||||||
|
if node := s.store(key, value); node != "" && s.OnKeys != nil {
|
||||||
|
s.OnKeys(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyGone forgets one machine key, and tells OnKeys its node.
|
||||||
|
func (s *Service) KeyGone(key string) {
|
||||||
|
node, _, _ := strings.Cut(key, ".")
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.keys, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.OnKeys != nil {
|
||||||
|
s.OnKeys(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// store keeps a machine key that reads as one: its node and account the key's own, and a public half that is a
|
||||||
|
// public key line and nothing private. It answers the node, or "" for a value it did not keep (and drops what was
|
||||||
|
// kept under that key, since the key now says something else).
|
||||||
|
func (s *Service) store(key string, value []byte) string {
|
||||||
|
node, account, _ := strings.Cut(key, ".")
|
||||||
|
var k machineKey
|
||||||
|
var raw struct {
|
||||||
|
Node, Account, Kind, Public string
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(value, &raw)
|
||||||
|
k = machineKey(raw)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) {
|
||||||
|
if _, had := s.keys[key]; had {
|
||||||
|
delete(s.keys, key)
|
||||||
|
}
|
||||||
|
s.cfg.Log("the key %s is not a machine's public key; it is not registered", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.keys[key] = k
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicKeyLine(line string) bool {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
return len(f) >= 2 && f[0] == "ssh-ed25519" && !strings.Contains(line, "PRIVATE") && !strings.ContainsAny(line, "\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// keysOf are the machine keys one node reported, by key.
|
||||||
|
func (s *Service) keysOf(node string) []machineKey {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var names []string
|
||||||
|
for key, k := range s.keys {
|
||||||
|
if k.Node == node && k.Kind == "machine" && strings.HasPrefix(key, node+".") {
|
||||||
|
names = append(names, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
out := make([]machineKey, 0, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
out = append(out, s.keys[n])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchKeys keeps the machines' keys as the bus holds them, asking again until the runtime answers (the channel to it
|
||||||
|
// opens as the bundle starts, and the state of ssh-client may arrive after this one). A change after the first
|
||||||
|
// values is told to OnKeys; the first values are not, since one pass answers them all.
|
||||||
|
func (s *Service) WatchKeys(ctx context.Context, src KeySource) error {
|
||||||
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||||
|
for attempt := 0; ; attempt++ {
|
||||||
|
err := src.Watch(func(key, op string, value []byte, current bool) {
|
||||||
|
switch {
|
||||||
|
case op != "put" && current:
|
||||||
|
case op != "put":
|
||||||
|
s.KeyGone(key)
|
||||||
|
case current:
|
||||||
|
s.store(key, value)
|
||||||
|
default:
|
||||||
|
s.KeyChanged(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
// The watch answers once the current values are all handled (stdio KeptState.Watch).
|
||||||
|
s.mu.Lock()
|
||||||
|
s.loaded = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pause := time.Minute
|
||||||
|
if attempt < len(waits) {
|
||||||
|
pause = waits[attempt]
|
||||||
|
}
|
||||||
|
s.cfg.Log("the machines' keys cannot be watched yet (%v); asking again in %s", err, pause)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
s.Pause(pause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostName is the forge machine's own mesh name, from the fact the mesh writes.
|
||||||
|
func (s *Service) hostName() (string, error) {
|
||||||
|
raw, err := os.ReadFile(s.cfg.HostFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the forge machine's mesh name cannot be read (%s): %w", s.cfg.HostFile, err)
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(string(raw))
|
||||||
|
if host == "" || strings.ContainsAny(host, " \t\n[]:/") {
|
||||||
|
return "", fmt.Errorf("%s holds %q, which is not a machine's mesh name", s.cfg.HostFile, host)
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpsBase is the forge's public https root with a trailing slash.
|
||||||
|
func (s *Service) httpsBase() (string, error) {
|
||||||
|
raw, err := os.ReadFile(s.cfg.OriginsFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the forge's origins cannot be read (%s): %w", s.cfg.OriginsFile, err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
line = strings.TrimRight(strings.TrimSpace(line), "/")
|
||||||
|
if strings.HasPrefix(line, "https://") {
|
||||||
|
return line + "/", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
|
||||||
|
func (s *Service) scan(ctx context.Context) (string, error) {
|
||||||
|
host, err := s.hostName()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
|
||||||
|
}
|
||||||
|
return knownHostLine(string(out), host, s.cfg.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownHostLine is the ed25519 line of ssh-keyscan's output with its host written as the mesh name and port.
|
||||||
|
func knownHostLine(scanned, host string, port int) (string, error) {
|
||||||
|
for _, line := range strings.Split(scanned, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) >= 3 && !strings.HasPrefix(f[0], "#") && f[1] == "ssh-ed25519" {
|
||||||
|
return fmt.Sprintf("[%s]:%d %s %s", host, port, f[1], f[2]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("the forge's sshd gave no ed25519 host key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// valuesFor are the values served to one machine's consumer (ADR 0400 §3, §4).
|
||||||
|
func (s *Service) valuesFor(node string) (map[string]any, error) {
|
||||||
|
user := forgeaccount.ForgeUser(node, "machine")
|
||||||
|
host, err := s.hostName()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
https, err := s.httpsBase()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
known := s.known
|
||||||
|
s.mu.Unlock()
|
||||||
|
if user == "" || known == "" {
|
||||||
|
return nil, fmt.Errorf("nothing is known to serve %s yet", node)
|
||||||
|
}
|
||||||
|
return map[string]any{"user": user, "ssh_host": host, "ssh_port": s.cfg.Port, "known_host": known,
|
||||||
|
"ssh_base": fmt.Sprintf("ssh://git@%s:%d/", host, s.cfg.Port), "https_base": https}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// valuesBus writes each consumer's entry with the values that differ per consumer or come from the machine's own
|
||||||
|
// facts. The provider helper's offer states what is the same for all; the helper has no place for the rest.
|
||||||
|
type valuesBus struct {
|
||||||
|
provider.Bus
|
||||||
|
s *Service
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
written map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *valuesBus) Put(ctx context.Context, bucket, key string, value []byte) error {
|
||||||
|
if bucket != provider.BucketOf(Module) {
|
||||||
|
return b.Bus.Put(ctx, bucket, key, value)
|
||||||
|
}
|
||||||
|
var e provider.Entry
|
||||||
|
if err := json.Unmarshal(value, &e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.Refused == "" {
|
||||||
|
extra, err := b.s.valuesFor(e.Consumer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.Values == nil {
|
||||||
|
e.Values = map[string]any{}
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
e.Values[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
same := bytes.Equal(b.written[key], out)
|
||||||
|
b.mu.Unlock()
|
||||||
|
if same {
|
||||||
|
return nil // the helper writes what it composed on every pass; this one stands
|
||||||
|
}
|
||||||
|
if err := b.Bus.Put(ctx, bucket, key, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.written[key] = out
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *valuesBus) Delete(ctx context.Context, bucket, key string) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.written, key)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return b.Bus.Delete(ctx, bucket, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||||
|
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pubA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
pubB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBus is the bus as the provider reads and writes it.
|
||||||
|
type fakeBus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBus() *fakeBus { return &fakeBus{buckets: map[string]map[string][]byte{}} }
|
||||||
|
|
||||||
|
func (b *fakeBus) set(bucket, key string, v []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.buckets[bucket] == nil {
|
||||||
|
b.buckets[bucket] = map[string][]byte{}
|
||||||
|
}
|
||||||
|
b.buckets[bucket][key] = v
|
||||||
|
}
|
||||||
|
func (b *fakeBus) Watch(_ context.Context, bucket string, h func(string, []byte)) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
cur := map[string][]byte{}
|
||||||
|
for k, v := range b.buckets[bucket] {
|
||||||
|
cur[k] = v
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
for k, v := range cur {
|
||||||
|
h(k, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (b *fakeBus) Put(_ context.Context, bucket, key string, v []byte) error {
|
||||||
|
b.set(bucket, key, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (b *fakeBus) Delete(_ context.Context, bucket, key string) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
delete(b.buckets[bucket], key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (b *fakeBus) entry(t *testing.T, key string) provider.Entry {
|
||||||
|
t.Helper()
|
||||||
|
b.mu.Lock()
|
||||||
|
raw := b.buckets[provider.BucketOf("gitea")][key]
|
||||||
|
b.mu.Unlock()
|
||||||
|
if raw == nil {
|
||||||
|
t.Fatalf("no entry %s", key)
|
||||||
|
}
|
||||||
|
var e provider.Entry
|
||||||
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
func (b *fakeBus) has(key string) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
_, ok := b.buckets[provider.BucketOf("gitea")][key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror.
|
||||||
|
func (b *fakeBus) bindClient(node string) {
|
||||||
|
b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf(
|
||||||
|
`{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`,
|
||||||
|
node, node)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeForge is gitea's API as far as the provider uses it.
|
||||||
|
type fakeForge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
users map[string]bool
|
||||||
|
teams map[string]bool
|
||||||
|
members []string
|
||||||
|
held map[string][]map[string]any
|
||||||
|
next int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newForge() *fakeForge {
|
||||||
|
return &fakeForge{users: map[string]bool{}, held: map[string][]map[string]any{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
|
||||||
|
|
||||||
|
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var in map[string]any
|
||||||
|
_ = json.Unmarshal(body, &in)
|
||||||
|
reply := func(code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if v != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := r.URL.Path
|
||||||
|
if m := keyRe.FindStringSubmatch(p); m != nil {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
reply(200, f.held[m[1]])
|
||||||
|
case http.MethodPost:
|
||||||
|
f.next++
|
||||||
|
f.held[m[1]] = append(f.held[m[1]], map[string]any{"id": f.next, "title": in["title"], "key": in["key"]})
|
||||||
|
reply(201, map[string]any{"id": f.next})
|
||||||
|
case http.MethodDelete:
|
||||||
|
var kept []map[string]any
|
||||||
|
for _, k := range f.held[m[1]] {
|
||||||
|
if fmt.Sprint(k["id"]) != m[2] {
|
||||||
|
kept = append(kept, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.held[m[1]] = kept
|
||||||
|
reply(204, nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"):
|
||||||
|
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
|
||||||
|
reply(404, map[string]any{"message": "no"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(200, map[string]any{"active": true})
|
||||||
|
case r.Method == http.MethodPost && p == "/api/v1/admin/users":
|
||||||
|
f.users[in["username"].(string)] = true
|
||||||
|
reply(201, map[string]any{})
|
||||||
|
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet:
|
||||||
|
reply(200, []map[string]any{{"id": 5, "name": "agents"}})
|
||||||
|
case p == "/api/v1/teams/5" && r.Method == http.MethodPatch:
|
||||||
|
reply(200, map[string]any{})
|
||||||
|
case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodPut:
|
||||||
|
f.members = append(f.members, strings.TrimPrefix(p, "/api/v1/teams/5/members/"))
|
||||||
|
reply(204, nil)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeForge) titles(user string) []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, k := range f.held[user] {
|
||||||
|
out = append(out, k["title"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
r.args = append([]string{name}, args...)
|
||||||
|
return []byte(r.out), r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanned = "# 127.0.0.1:222 SSH-2.0-Go\n[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY\n"
|
||||||
|
|
||||||
|
type rig struct {
|
||||||
|
t *testing.T
|
||||||
|
bus *fakeBus
|
||||||
|
forge *fakeForge
|
||||||
|
svc *Service
|
||||||
|
runner *fakeRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRig(t *testing.T) *rig {
|
||||||
|
t.Helper()
|
||||||
|
forge := newForge()
|
||||||
|
srv := httptest.NewServer(forge)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
dir := t.TempDir()
|
||||||
|
pw := filepath.Join(dir, "pw")
|
||||||
|
host := filepath.Join(dir, "host")
|
||||||
|
origins := filepath.Join(dir, "origins")
|
||||||
|
for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} {
|
||||||
|
if err := os.WriteFile(p, []byte(c), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runner := &fakeRunner{out: scanned}
|
||||||
|
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
||||||
|
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner,
|
||||||
|
Log: func(string, ...any) {}})
|
||||||
|
svc.loaded = true // the machines' keys are read; a test that means otherwise says so
|
||||||
|
return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rig) pass() []provider.Outcome {
|
||||||
|
r.t.Helper()
|
||||||
|
p := r.svc.Provider(r.bus)
|
||||||
|
out, err := p.Pass(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rig) key(node, account, public string) {
|
||||||
|
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": "machine", "public": public,
|
||||||
|
"fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"})
|
||||||
|
r.svc.KeyChanged(node+"."+account, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerWithAKeyGetsItsEntryAndItsKeyIsRegistered(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
e := r.bus.entry(t, "g14.ssh-client.forge-account")
|
||||||
|
want := map[string]any{"user": "agent-g14", "ssh_host": "novox.internal", "ssh_port": float64(222),
|
||||||
|
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY", "ssh_base": "ssh://git@novox.internal:222/",
|
||||||
|
"https_base": "https://git.novox.be/"}
|
||||||
|
for k, v := range want {
|
||||||
|
if e.Values[k] != v {
|
||||||
|
t.Errorf("%s = %v, want %v", k, e.Values[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.VaultName != "" || e.User != "" {
|
||||||
|
t.Errorf("a public key is no secret; the entry names a credential: %+v", e)
|
||||||
|
}
|
||||||
|
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" {
|
||||||
|
t.Errorf("registered keys = %v", got)
|
||||||
|
}
|
||||||
|
if !r.forge.users["agent-g14"] || len(r.forge.members) != 1 || r.forge.members[0] != "agent-g14" {
|
||||||
|
t.Errorf("account %v, team members %v", r.forge.users, r.forge.members)
|
||||||
|
}
|
||||||
|
if strings.Join(r.runner.args, " ") != "ssh-keyscan -t ed25519 -p 222 127.0.0.1" {
|
||||||
|
t.Errorf("scan = %v", r.runner.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerWithoutAKeyGetsNoEntryAndNoAccount(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("ace", "nox", pubB) // another machine's key is not g14's
|
||||||
|
out := r.pass()
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("an entry was written for a machine that reported no key")
|
||||||
|
}
|
||||||
|
if len(r.forge.users) != 0 {
|
||||||
|
t.Fatalf("an account was made: %v", r.forge.users)
|
||||||
|
}
|
||||||
|
if len(out) != 1 || !strings.Contains(out[0].Why, "no key") {
|
||||||
|
t.Fatalf("the wait should be said: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeyOfAnotherKindIsNotTheMachinesKey(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
b, _ := json.Marshal(map[string]string{"node": "g14", "account": "x", "kind": "session", "public": pubA})
|
||||||
|
r.svc.KeyChanged("g14.x", b)
|
||||||
|
r.pass()
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("answered with a key that is not a machine's")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAChangedKeyReplacesTheOldMeshKey(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
r.forge.mu.Lock()
|
||||||
|
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
||||||
|
r.forge.mu.Unlock()
|
||||||
|
r.key("g14", "nox", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
got := strings.Join(r.forge.titles("agent-g14"), ",")
|
||||||
|
if got != "my own,mesh:g14.nox" {
|
||||||
|
t.Fatalf("keys = %s", got)
|
||||||
|
}
|
||||||
|
for _, k := range r.forge.held["agent-g14"] {
|
||||||
|
if k["title"] == "mesh:g14.nox" && !strings.Contains(k["key"].(string), "CCCC") {
|
||||||
|
t.Fatalf("the old key stayed: %v", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
r.forge.mu.Lock()
|
||||||
|
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
||||||
|
r.forge.mu.Unlock()
|
||||||
|
r.bus.mu.Lock()
|
||||||
|
delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client")
|
||||||
|
r.bus.mu.Unlock()
|
||||||
|
r.pass()
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("the entry stayed")
|
||||||
|
}
|
||||||
|
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" {
|
||||||
|
t.Fatalf("keys = %v", got)
|
||||||
|
}
|
||||||
|
if !r.forge.users["agent-g14"] {
|
||||||
|
t.Fatal("the account was removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeyChangeNamesTheConsumersToAnswerAgain(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
var got []string
|
||||||
|
r.svc.OnKeys = func(node string) { got = append(got, node) }
|
||||||
|
r.key("g14", "nox", pubA)
|
||||||
|
if len(got) != 1 || got[0] != "g14" {
|
||||||
|
t.Fatalf("answered for %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAKeyThatIsNotReadableIsNotKept(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","kind":"machine"`))
|
||||||
|
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"PRIVATE KEY"}`))
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.pass()
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("a value that is no public key was registered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheForgesHostKeyUnreadableMeansNoEntry(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.runner.err = errors.New("connection refused")
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA)
|
||||||
|
r.pass()
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("an entry without the forge's host key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownHostLineIsRewrittenToTheMeshName(t *testing.T) {
|
||||||
|
line, err := knownHostLine(scanned, "novox.internal", 222)
|
||||||
|
if err != nil || line != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
|
||||||
|
t.Fatalf("%q %v", line, err)
|
||||||
|
}
|
||||||
|
if _, err := knownHostLine("# nothing\n", "novox.internal", 222); err == nil {
|
||||||
|
t.Fatal("no key line was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
src := &fakeKeys{failFirst: 2}
|
||||||
|
r.svc.Pause = func(time.Duration) {}
|
||||||
|
r.svc.loaded = false
|
||||||
|
passes := 0
|
||||||
|
r.svc.OnKeys = func(string) { passes++ }
|
||||||
|
if err := r.svc.WatchKeys(context.Background(), src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.svc.loaded {
|
||||||
|
t.Fatal("the keys are not loaded once the watch has answered")
|
||||||
|
}
|
||||||
|
if src.calls != 3 {
|
||||||
|
t.Fatalf("asked %d times", src.calls)
|
||||||
|
}
|
||||||
|
src.send("g14.nox", "put", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"`+pubA+`"}`), false)
|
||||||
|
if passes != 1 {
|
||||||
|
t.Fatalf("passes = %d", passes)
|
||||||
|
}
|
||||||
|
// What was there when the watch began is taken in, and one pass answers it, not one per key.
|
||||||
|
src.send("ace.nox", "put", []byte(`{"node":"ace","account":"nox","kind":"machine","public":"`+pubB+`"}`), true)
|
||||||
|
if passes != 1 {
|
||||||
|
t.Fatalf("a current value passed on its own: %d", passes)
|
||||||
|
}
|
||||||
|
src.send("g14.nox", "delete", nil, false)
|
||||||
|
if passes != 2 || len(r.svc.keysOf("g14")) != 0 {
|
||||||
|
t.Fatalf("passes = %d, keys = %v", passes, r.svc.keysOf("g14"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeKeys struct {
|
||||||
|
failFirst int
|
||||||
|
calls int
|
||||||
|
handle func(key, op string, value []byte, current bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
|
||||||
|
f.calls++
|
||||||
|
if f.calls <= f.failFirst {
|
||||||
|
return errors.New("not yet")
|
||||||
|
}
|
||||||
|
f.handle = handle
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) }
|
||||||
|
|
||||||
|
func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
r.forge.mu.Lock()
|
||||||
|
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
||||||
|
r.forge.mu.Unlock()
|
||||||
|
r.svc.KeyGone("g14.nox")
|
||||||
|
r.pass()
|
||||||
|
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" {
|
||||||
|
t.Fatalf("keys = %v", got)
|
||||||
|
}
|
||||||
|
if r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("the entry still serves a machine whose key is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) {
|
||||||
|
r := newRig(t)
|
||||||
|
r.bus.bindClient("g14")
|
||||||
|
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
|
||||||
|
r.pass()
|
||||||
|
r.forge.mu.Lock()
|
||||||
|
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
|
||||||
|
r.forge.mu.Unlock()
|
||||||
|
// A restart: the keys are not read yet, so a node with none is not a node that reported none.
|
||||||
|
r.svc.mu.Lock()
|
||||||
|
r.svc.keys = map[string]machineKey{}
|
||||||
|
r.svc.loaded = false
|
||||||
|
r.svc.mu.Unlock()
|
||||||
|
out := r.pass()
|
||||||
|
if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "mesh:g14.nox,my own" {
|
||||||
|
t.Fatalf("keys = %s", got)
|
||||||
|
}
|
||||||
|
if !r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatal("the entry was deleted before the keys were read")
|
||||||
|
}
|
||||||
|
if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") {
|
||||||
|
t.Fatalf("the wait should be said: %+v", out)
|
||||||
|
}
|
||||||
|
// Loaded, and the node reports none: now its keys go.
|
||||||
|
r.svc.mu.Lock()
|
||||||
|
r.svc.loaded = true
|
||||||
|
r.svc.mu.Unlock()
|
||||||
|
r.pass()
|
||||||
|
if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "my own" || r.bus.has("g14.ssh-client.forge-account") {
|
||||||
|
t.Fatalf("keys = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
${consumers:forge-account}
|
||||||
+14
-2
@@ -2,9 +2,21 @@ module gitea
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
|
github.com/nats-io/nats.go v1.51.0
|
||||||
|
golang.org/x/crypto v0.50.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/crypto v0.50.0
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+25
-4
@@ -1,6 +1,27 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||||
|
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||||
|
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||||
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR
|
||||||
|
// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write
|
||||||
|
// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says
|
||||||
|
// what an agent's account is.
|
||||||
|
package forgeaccount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
|
||||||
|
AccountPrefix = "agent-"
|
||||||
|
// TeamOrg is the organisation whose repositories are the mesh's.
|
||||||
|
TeamOrg = "novox"
|
||||||
|
// TeamName is the one team every agent's account is in (ADR 0398 §1).
|
||||||
|
TeamName = "agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the
|
||||||
|
// organisation and no more, the shape of the forge tools' team (tools-account.ts).
|
||||||
|
func teamShape() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||||
|
"units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is gitea's account as the admin reads it.
|
||||||
|
type User struct {
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
ProhibitLogin bool `json:"prohibit_login"`
|
||||||
|
Restricted bool `json:"restricted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts makes and keeps the agents' accounts with the administrator's login.
|
||||||
|
type Accounts struct {
|
||||||
|
Forge forgehttp.Client
|
||||||
|
// Password makes the password an account is created with: random, sent once and kept nowhere.
|
||||||
|
Password func() string
|
||||||
|
Log func(format string, a ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never
|
||||||
|
// an administrator, active, able to use its token. Its team is EnsureInTeam's.
|
||||||
|
func (m *Accounts) EnsureAccount(ctx context.Context, account string) error {
|
||||||
|
var u User
|
||||||
|
err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, forgehttp.ErrNotFound):
|
||||||
|
password := m.password()
|
||||||
|
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||||
|
"username": account, "email": account + "@noreply.localhost", "password": password,
|
||||||
|
"must_change_password": false, "visibility": "private",
|
||||||
|
}, nil)
|
||||||
|
password = ""
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the account %s could not be made: %w", account, err)
|
||||||
|
}
|
||||||
|
m.say("made the forge account %s for that machine's agent, in no team yet", account)
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("the account %s could not be read: %w", account, err)
|
||||||
|
}
|
||||||
|
if u.IsAdmin || !u.Active || u.ProhibitLogin {
|
||||||
|
if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{
|
||||||
|
"login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false,
|
||||||
|
}, nil); err != nil {
|
||||||
|
return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err)
|
||||||
|
}
|
||||||
|
m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again",
|
||||||
|
account, u.IsAdmin, u.Active, u.ProhibitLogin)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the
|
||||||
|
// account to it; an account already in it stays (ADR 0398 §1, issue 948).
|
||||||
|
func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error {
|
||||||
|
var id int64
|
||||||
|
for page := 1; page <= 20 && id == 0; page++ {
|
||||||
|
var teams []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil {
|
||||||
|
return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
||||||
|
}
|
||||||
|
for _, t := range teams {
|
||||||
|
if t.Name == TeamName {
|
||||||
|
id = t.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(teams) < 50 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id != 0 {
|
||||||
|
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil {
|
||||||
|
return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var made struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil {
|
||||||
|
return fmt.Errorf("the team %s could not be made: %w", TeamName, err)
|
||||||
|
}
|
||||||
|
id = made.ID
|
||||||
|
m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return fmt.Errorf("the forge answered no id for the team %s", TeamName)
|
||||||
|
}
|
||||||
|
if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil {
|
||||||
|
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Accounts) password() string {
|
||||||
|
if m.Password != nil {
|
||||||
|
return m.Password()
|
||||||
|
}
|
||||||
|
b := make([]byte, 24)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Accounts) say(format string, a ...any) {
|
||||||
|
if m.Log != nil {
|
||||||
|
m.Log(format, a...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeName is what a machine's name is, for it to name an account.
|
||||||
|
var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||||
|
|
||||||
|
// ForgeUser is the forge user of a machine's key of the given kind (ADR 0400 §3): `agent-<node>` for the machine's
|
||||||
|
// own account (kind machine). A kind not built yet has none, and the answer is empty.
|
||||||
|
func ForgeUser(node, kind string) string {
|
||||||
|
if kind == "machine" && NodeName.MatchString(node) {
|
||||||
|
return AccountPrefix + node
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package forgeaccount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitlePrefix starts the title of every key the mesh registers; a key without it is the operator's and is never
|
||||||
|
// touched (ADR 0399 §2, ADR 0400).
|
||||||
|
const TitlePrefix = "mesh:"
|
||||||
|
|
||||||
|
// KeyTitle is the title of a machine account's key: `mesh:<node>.<account>`.
|
||||||
|
func KeyTitle(node, account string) string { return TitlePrefix + node + "." + account }
|
||||||
|
|
||||||
|
// WantedKey is a public key the mesh wants registered, under its title.
|
||||||
|
type WantedKey struct {
|
||||||
|
Title string
|
||||||
|
// Key is the public key line: type, base64 and a comment.
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is a key the forge holds for an account.
|
||||||
|
type Key struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// material is a public key line without its comment: its type and its base64, which is what makes it the same key.
|
||||||
|
func material(line string) string {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 2 {
|
||||||
|
return strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
return f[0] + " " + f[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys lists the SSH keys the account holds.
|
||||||
|
func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) {
|
||||||
|
var all []Key
|
||||||
|
for page := 1; page <= 20; page++ {
|
||||||
|
var got []Key
|
||||||
|
if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/users/%s/keys?limit=50&page=%d", user, page), &got); err != nil {
|
||||||
|
return nil, fmt.Errorf("the keys of %s could not be read: %w", user, err)
|
||||||
|
}
|
||||||
|
all = append(all, got...)
|
||||||
|
if len(got) < 50 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddKey registers a key for the account, one it may write with.
|
||||||
|
func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error {
|
||||||
|
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys",
|
||||||
|
map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteKey removes one key of the account, by its id.
|
||||||
|
func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error {
|
||||||
|
err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/users/%s/keys/%d", user, id), nil, nil)
|
||||||
|
if err != nil && !errors.Is(err, forgehttp.ErrNotFound) {
|
||||||
|
return fmt.Errorf("the key %d of %s could not be deleted: %w", id, user, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered
|
||||||
|
// (title starting `mesh:`) that are no longer wanted. A key whose title does not start `mesh:` is the operator's and
|
||||||
|
// is never touched, and a wanted key the account already holds under another title is left as it is (ADR 0399 §2).
|
||||||
|
// A key is added before another title's is removed, so an account is not left without one.
|
||||||
|
func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedKey) error {
|
||||||
|
held, err := m.Keys(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, k := range held {
|
||||||
|
have[material(k.Key)] = true
|
||||||
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, w := range wanted {
|
||||||
|
want[material(w.Key)] = true
|
||||||
|
}
|
||||||
|
gone := map[int64]bool{}
|
||||||
|
remove := func(k Key) error {
|
||||||
|
if gone[k.ID] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
gone[k.ID] = true
|
||||||
|
if err := m.DeleteKey(ctx, user, k.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.say("removed the key %s of %s: it is no longer wanted", k.Title, user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, w := range wanted {
|
||||||
|
if have[material(w.Key)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The forge holds a title once: the mesh's key of the same title, now another key, goes first.
|
||||||
|
for _, k := range held {
|
||||||
|
if k.Title == w.Title && strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] {
|
||||||
|
if err := remove(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.AddKey(ctx, user, w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.say("registered the key %s for %s", w.Title, user)
|
||||||
|
}
|
||||||
|
for _, k := range held {
|
||||||
|
if strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] {
|
||||||
|
if err := remove(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveKeysTitled removes the account's keys whose title starts with prefix, and only a prefix the mesh gave
|
||||||
|
// (starting `mesh:`, such as `mesh:g14.`): what the mesh registered for one machine, never a key of the operator's
|
||||||
|
// (ADR 0399 §2).
|
||||||
|
func (m *Accounts) RemoveKeysTitled(ctx context.Context, user, prefix string) error {
|
||||||
|
if !strings.HasPrefix(prefix, TitlePrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
held, err := m.Keys(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range held {
|
||||||
|
if !strings.HasPrefix(k.Title, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.DeleteKey(ctx, user, k.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.say("removed the key %s of %s: its machine no longer binds the forge", k.Title, user)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package forgeaccount
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea/internal/forgehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type heldKey struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyForge is the part of gitea's API the keys use, as the administrator's login reaches it (issue 955 F2).
|
||||||
|
type keyForge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
keys map[string][]heldKey
|
||||||
|
next int64
|
||||||
|
calls []string
|
||||||
|
posts []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
var keysPath = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
|
||||||
|
|
||||||
|
func (f *keyForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
|
||||||
|
m := keysPath.FindStringSubmatch(r.URL.Path)
|
||||||
|
if m == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := m[1]
|
||||||
|
reply := func(status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if v != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && m[2] == "" && !strings.Contains(r.URL.Path, "/admin/"):
|
||||||
|
reply(200, f.keys[user])
|
||||||
|
case r.Method == http.MethodPost && m[2] == "" && strings.Contains(r.URL.Path, "/admin/"):
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var in map[string]any
|
||||||
|
_ = json.Unmarshal(body, &in)
|
||||||
|
f.posts = append(f.posts, in)
|
||||||
|
key, _ := in["key"].(string)
|
||||||
|
for _, k := range f.keys[user] {
|
||||||
|
if k.Key == key {
|
||||||
|
reply(422, map[string]any{"message": "Key content has been used as non-deploy key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title, _ := in["title"].(string)
|
||||||
|
for _, k := range f.keys[user] {
|
||||||
|
if k.Title == title {
|
||||||
|
reply(422, map[string]any{"message": "Key title has been used"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.next++
|
||||||
|
f.keys[user] = append(f.keys[user], heldKey{ID: f.next, Title: title, Key: key})
|
||||||
|
reply(201, map[string]any{"id": f.next})
|
||||||
|
case r.Method == http.MethodDelete && m[2] != "" && strings.Contains(r.URL.Path, "/admin/"):
|
||||||
|
var kept []heldKey
|
||||||
|
for _, k := range f.keys[user] {
|
||||||
|
if fmt.Sprint(k.ID) != m[2] {
|
||||||
|
kept = append(kept, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.keys[user] = kept
|
||||||
|
reply(204, nil)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||||
|
)
|
||||||
|
|
||||||
|
func keyAccounts(t *testing.T, f *keyForge) *Accounts {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(f)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
pw := filepath.Join(t.TempDir(), "pw")
|
||||||
|
if err := os.WriteFile(pw, []byte("x\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &Accounts{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Log: func(string, ...any) {}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysAddsTheWantedKeyUnderItsTitle(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{}}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.posts) != 1 || f.posts[0]["title"] != "mesh:g14.nox" || f.posts[0]["read_only"] != false ||
|
||||||
|
f.posts[0]["key"] != keyA+" nox@g14 (mesh)" {
|
||||||
|
t.Fatalf("posts = %v", f.posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysAgainChangesNothing(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{}}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
want := []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}
|
||||||
|
for range 2 {
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.posts) != 1 || len(f.keys["agent-g14"]) != 1 {
|
||||||
|
t.Fatalf("posts = %v, held = %v", f.posts, f.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysReplacesAChangedMeshKeyOfTheSameTitle(t *testing.T) {
|
||||||
|
// The forge refuses a title held twice, so the old key goes first.
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.nox", Key: keyA}}}, next: 7}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB + " nox@g14 (mesh)"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := f.keys["agent-g14"]
|
||||||
|
if len(held) != 1 || !strings.HasPrefix(held[0].Key, keyB) {
|
||||||
|
t.Fatalf("held = %v", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysRemovesAMeshKeyOfAnotherTitleAfterTheNewOneIsIn(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.old", Key: keyA}}}, next: 7}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var post, del int
|
||||||
|
for i, c := range f.calls {
|
||||||
|
if strings.HasPrefix(c, "POST") {
|
||||||
|
post = i
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(c, "DELETE") {
|
||||||
|
del = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if post == 0 || del == 0 || post > del || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "mesh:g14.nox" {
|
||||||
|
t.Fatalf("calls = %v, held = %v", f.calls, f.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysNeverTouchesAKeyTheOperatorAdded(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{"agent-g14": {
|
||||||
|
{ID: 3, Title: "my laptop", Key: keyB},
|
||||||
|
{ID: 7, Title: "mesh:g14.nox", Key: keyA},
|
||||||
|
}}, next: 7}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
// Nothing wanted any more: the mesh's key goes, the operator's stays.
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := f.keys["agent-g14"]
|
||||||
|
if len(held) != 1 || held[0].Title != "my laptop" {
|
||||||
|
t.Fatalf("held = %v", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureKeysLeavesAWantedKeyTheOperatorAlreadyHoldsUnderAnotherTitle(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 3, Title: "by hand", Key: keyA}}}, next: 3}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.posts) != 0 || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "by hand" {
|
||||||
|
t.Fatalf("posts = %v, held = %v", f.posts, f.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package forgeaccount
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestForgeUserOfAMachineAccountIsTheNodesAgent(t *testing.T) {
|
||||||
|
if got := ForgeUser("g14", "machine"); got != "agent-g14" {
|
||||||
|
t.Fatalf("ForgeUser = %q", got)
|
||||||
|
}
|
||||||
|
if got := ForgeUser("g14", "other"); got != "" {
|
||||||
|
t.Fatalf("a kind not built yet has no forge user, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
|
||||||
|
f := &keyForge{keys: map[string][]heldKey{"agent-g14": {
|
||||||
|
{ID: 1, Title: "mesh:g14.nox", Key: keyA},
|
||||||
|
{ID: 2, Title: "mesh:g14.other", Key: keyB},
|
||||||
|
{ID: 3, Title: "by hand", Key: "ssh-ed25519 CCCC"},
|
||||||
|
}}, next: 3}
|
||||||
|
a := keyAccounts(t, f)
|
||||||
|
if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "mesh:g14.nox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := f.keys["agent-g14"]; len(got) != 2 || got[0].ID != 2 || got[1].ID != 3 {
|
||||||
|
t.Fatalf("held = %v", got)
|
||||||
|
}
|
||||||
|
if err := a.RemoveKeysTitled(t.Context(), "agent-g14", "by hand"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.keys["agent-g14"]) != 2 {
|
||||||
|
t.Fatal("a key that is not the mesh's was removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,6 +177,12 @@
|
|||||||
"default": "mesh-tools mesh-admin jschoubben"
|
"default": "mesh-tools mesh-admin jschoubben"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"facts": {
|
||||||
|
"forge-host": {
|
||||||
|
"path": "/etc/gitea/forge-host",
|
||||||
|
"template": "{{range .Machines}}{{if eq .Name $.Node}}{{.FQDN}}{{end}}{{end}}\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -313,6 +319,13 @@
|
|||||||
"agent-read-teams"
|
"agent-read-teams"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "consumers",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${dir:state}/consumers.json",
|
||||||
|
"mode": "0600",
|
||||||
|
"template": "files/consumers.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "agent-teams",
|
"id": "agent-teams",
|
||||||
"type": "process",
|
"type": "process",
|
||||||
@@ -353,6 +366,10 @@
|
|||||||
"issued-by": "software",
|
"issued-by": "software",
|
||||||
"tool": "gitea_agent_token"
|
"tool": "gitea_agent_token"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "forge-account",
|
||||||
|
"scope": "mesh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -451,6 +468,25 @@
|
|||||||
"loads": [
|
"loads": [
|
||||||
"agent-teams"
|
"agent-teams"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "forge-account-provider",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/forge-account-provider",
|
||||||
|
"binary": "forge-account-provider",
|
||||||
|
"loads": [
|
||||||
|
"forge-account-provider"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
|
||||||
|
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||||
|
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
|
||||||
|
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins",
|
||||||
|
"MESH_GITEA_SSH_PORT": "222",
|
||||||
|
"MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -460,5 +496,11 @@
|
|||||||
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
|
||||||
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"state": [
|
||||||
|
"bound"
|
||||||
|
],
|
||||||
|
"reads": [
|
||||||
|
"ssh-client.keys"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// The key this machine's account uses to reach the forge over SSH (novox/hq ADR 0399, ADR 0400; issue 955,
|
||||||
|
// area S1). It is made here, on the machine, once: ssh-keygen writes ~/.ssh/mesh_ed25519 only when it is
|
||||||
|
// missing, so the private half never leaves the machine and an existing key is never replaced. The public
|
||||||
|
// half and its fingerprint go into this module's `keys` state, one entry per account under <node>.<account>,
|
||||||
|
// for the forge's provider to register. The tools bundle is the one process of this module, so the keeper
|
||||||
|
// runs inside it beside the tools (main.go), the way claude-code reports what it holds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyFile is the key's name under ~/.ssh: never one of the operator's own id_* files.
|
||||||
|
const KeyFile = "mesh_ed25519"
|
||||||
|
|
||||||
|
// KeyKind is the kind of key this process makes: the machine's account's own (ADR 0400 §1).
|
||||||
|
const KeyKind = "machine"
|
||||||
|
|
||||||
|
// KeyRetry is how soon it looks again after a failure.
|
||||||
|
const KeyRetry = 30 * time.Second
|
||||||
|
|
||||||
|
// KeyRound is how often the keeper looks again.
|
||||||
|
const KeyRound = 10 * time.Minute
|
||||||
|
|
||||||
|
// KeyEntry is one value of the keys state. It carries the public half only.
|
||||||
|
type KeyEntry struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Public string `json:"public"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPutter is the keys state, as far as the keeper needs it.
|
||||||
|
type KeyPutter interface {
|
||||||
|
Put(key string, value any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyKeeper keeps one account's key and its entry.
|
||||||
|
type KeyKeeper struct {
|
||||||
|
Home, Node, Account string
|
||||||
|
Run Runner
|
||||||
|
State KeyPutter
|
||||||
|
Now func() time.Time
|
||||||
|
|
||||||
|
reported string // the public half and fingerprint last put
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KeyKeeper) path() string { return filepath.Join(k.Home, ".ssh", KeyFile) }
|
||||||
|
|
||||||
|
// Once makes the key when it is missing and puts the entry when it differs from the one last put.
|
||||||
|
func (k *KeyKeeper) Once(ctx context.Context) error {
|
||||||
|
if err := k.ensure(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(k.path() + ".pub")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the public half: %w", err)
|
||||||
|
}
|
||||||
|
pk, err := ParseKeyLine(string(raw))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s.pub: %w", k.path(), err)
|
||||||
|
}
|
||||||
|
public := strings.TrimSpace(string(raw))
|
||||||
|
if sig := public + "|" + pk.Fingerprint; sig == k.reported {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry := KeyEntry{Node: k.Node, Account: k.Account, Kind: KeyKind, Public: public,
|
||||||
|
Fingerprint: pk.Fingerprint, At: k.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if err := k.State.Put(k.Node+"."+k.Account, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k.reported = public + "|" + pk.Fingerprint
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure makes the pair when the private half is missing. Never over an existing key.
|
||||||
|
func (k *KeyKeeper) ensure(ctx context.Context) error {
|
||||||
|
if _, err := os.Lstat(k.path()); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(k.path()), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := k.Run(ctx, nil, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", k.Account+"@"+k.Node+" (mesh)", "-f", k.path())
|
||||||
|
if r.Status != 0 || r.Err != "" {
|
||||||
|
return fmt.Errorf("ssh-keygen: %s", firstLine(r.Err+" "+r.Stderr))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep runs Once now and every KeyRound, reporting a failure through say and trying again at the next round.
|
||||||
|
func (k *KeyKeeper) Keep(ctx context.Context, say func(string, ...any)) {
|
||||||
|
for {
|
||||||
|
pause := KeyRound
|
||||||
|
if err := k.Once(ctx); err != nil {
|
||||||
|
say("the forge key: %v", err)
|
||||||
|
pause = KeyRetry // the state or the bus's grant may arrive after the process
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(pause):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats-server/v2/server"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 955, area S4. The acceptance: "a machine's account makes its forge key, and the key's public half is
|
||||||
|
// in ssh-client's keys state under <node>.<account>" — proved over a real bus. A JetStream server is linked into
|
||||||
|
// the test and started for it alone; the module is this test binary started again as the bundle, speaking MCP on
|
||||||
|
// its standard streams to a runtime played here, which keeps the state in buckets of the bus as the tool runner
|
||||||
|
// does. It runs in the forge's merge check, not on the way: `go test -short` leaves it out.
|
||||||
|
|
||||||
|
const bundleVar = "MESH_SSH_CLIENT_TEST_BUNDLE"
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv(bundleVar) == "1" {
|
||||||
|
main()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
type keysRuntime struct {
|
||||||
|
t *testing.T
|
||||||
|
js jetstream.JetStream
|
||||||
|
in io.WriteCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]jetstream.KeyValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *keysRuntime) bucket(name string) jetstream.KeyValue {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if kv, ok := r.buckets[name]; ok {
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
kv, err := r.js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name})
|
||||||
|
if err != nil {
|
||||||
|
r.t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.buckets[name] = kv
|
||||||
|
return kv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *keysRuntime) say(m map[string]any) {
|
||||||
|
m["jsonrpc"] = "2.0"
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, _ = r.in.Write(append(raw, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *keysRuntime) read(out io.Reader) {
|
||||||
|
sc := bufio.NewScanner(out)
|
||||||
|
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||||
|
for sc.Scan() {
|
||||||
|
var m struct {
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(sc.Bytes(), &m) != nil || m.Method == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go r.runtime(m.ID, m.Method, m.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtime is what the tool runner does for the bundle: only the state this module puts.
|
||||||
|
func (r *keysRuntime) runtime(id json.RawMessage, method string, params json.RawMessage) {
|
||||||
|
var p struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(params, &p)
|
||||||
|
if method != "mesh/state.put" {
|
||||||
|
r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": fmt.Sprintf("the test runtime offers no %s", method)}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rev, err := r.bucket(p.State).Put(context.Background(), p.Key, p.Value)
|
||||||
|
if err != nil {
|
||||||
|
r.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.say(map[string]any{"id": id, "result": map[string]any{"revision": rev}})
|
||||||
|
}
|
||||||
|
|
||||||
|
type keysLog struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (l keysLog) Write(p []byte) (int, error) { l.t.Logf("bundle: %s", p); return len(p), nil }
|
||||||
|
|
||||||
|
func TestTheKeyProcessMakesTheKeyAndPutsItsEntryOverTheBus(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("integration test: runs in the forge's merge check")
|
||||||
|
}
|
||||||
|
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go s.Start()
|
||||||
|
if !s.ReadyForConnections(30 * time.Second) {
|
||||||
|
t.Fatal("no bus within 30s")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
||||||
|
nc, err := nats.Connect(s.ClientURL())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(nc.Close)
|
||||||
|
js, _ := jetstream.New(nc)
|
||||||
|
r := &keysRuntime{t: t, js: js, buckets: map[string]jetstream.KeyValue{}}
|
||||||
|
|
||||||
|
account, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := t.TempDir()
|
||||||
|
path := os.Getenv("PATH")
|
||||||
|
if _, err := exec.LookPath("ssh-keygen"); err != nil {
|
||||||
|
// A build host without OpenSSH: a stand-in that writes the pair the command names.
|
||||||
|
bin := t.TempDir()
|
||||||
|
stub := "#!/bin/sh\nwhile [ $# -gt 0 ]; do case $1 in -f) f=$2;; -C) c=$2;; esac; shift; done\n" +
|
||||||
|
"printf 'PRIVATE\\n' > \"$f\"; chmod 600 \"$f\"\n" +
|
||||||
|
"printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA %s\\n' \"$c\" > \"$f.pub\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "ssh-keygen"), []byte(stub), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path = bin + string(os.PathListSeparator) + path
|
||||||
|
t.Log("no ssh-keygen on this host: using a stand-in")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||||
|
cmd.Env = append(os.Environ(), bundleVar+"=1", "PATH="+path, "MESH_OPERATOR_HOME="+home,
|
||||||
|
"MESH_SERVED_MODULE=ssh-client", "MESH_MODULE=ssh-client", "MESH_NODE=test")
|
||||||
|
in, _ := cmd.StdinPipe()
|
||||||
|
out, _ := cmd.StdoutPipe()
|
||||||
|
cmd.Stderr = keysLog{t}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.in = in
|
||||||
|
go r.read(out)
|
||||||
|
t.Cleanup(func() { _ = in.Close(); _ = cmd.Wait() })
|
||||||
|
|
||||||
|
key := "test." + account.Username
|
||||||
|
var raw []byte
|
||||||
|
for deadline := time.Now().Add(60 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||||
|
if e, err := r.bucket("keys").Get(context.Background(), key); err == nil {
|
||||||
|
raw = e.Value()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw == nil {
|
||||||
|
t.Fatalf("no entry %s in the keys state within 60 s", key)
|
||||||
|
}
|
||||||
|
var e KeyEntry
|
||||||
|
if err := json.Unmarshal(raw, &e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(e.Public, "ssh-ed25519 ") || e.Node != "test" || e.Account != account.Username || e.Kind != "machine" || !strings.HasPrefix(e.Fingerprint, "SHA256:") {
|
||||||
|
t.Errorf("entry = %s", raw)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||||
|
t.Errorf("the entry carries private material: %s", raw)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(home, ".ssh", KeyFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("the private key is %04o, want 0600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 955, area S1: the key the machine's account makes for the forge, and its entry in the keys state.
|
||||||
|
|
||||||
|
type fakeState struct{ puts map[string][]byte }
|
||||||
|
|
||||||
|
func (f *fakeState) Put(key string, value any) error {
|
||||||
|
raw, _ := json.Marshal(value)
|
||||||
|
if f.puts == nil {
|
||||||
|
f.puts = map[string][]byte{}
|
||||||
|
}
|
||||||
|
f.puts[key] = raw
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ed25519Line is a public key line with a well-formed ed25519 blob.
|
||||||
|
func ed25519Line(seed byte, comment string) string {
|
||||||
|
blob := []byte{}
|
||||||
|
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
||||||
|
part2 := append([]byte(nil), part...)
|
||||||
|
if len(part2) == 32 {
|
||||||
|
part2[0] = seed
|
||||||
|
}
|
||||||
|
l := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(l, uint32(len(part2)))
|
||||||
|
blob = append(append(blob, l...), part2...)
|
||||||
|
}
|
||||||
|
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// keygenRunner stands in for ssh-keygen -t ed25519: it writes the pair the command names.
|
||||||
|
func keygenRunner(calls *[][]string) Runner {
|
||||||
|
return func(ctx context.Context, env []string, name string, args ...string) Ran {
|
||||||
|
*calls = append(*calls, append([]string{name}, args...))
|
||||||
|
var file, comment string
|
||||||
|
for i, a := range args {
|
||||||
|
switch a {
|
||||||
|
case "-f":
|
||||||
|
file = args[i+1]
|
||||||
|
case "-C":
|
||||||
|
comment = args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name != "ssh-keygen" || file == "" {
|
||||||
|
return Ran{Status: 1, Stderr: "unexpected"}
|
||||||
|
}
|
||||||
|
os.WriteFile(file, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE-SECRET\n"), 0o600)
|
||||||
|
os.WriteFile(file+".pub", []byte(ed25519Line(7, comment)+"\n"), 0o644)
|
||||||
|
return Ran{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeeper(t *testing.T, home string, st *fakeState, calls *[][]string) *KeyKeeper {
|
||||||
|
return &KeyKeeper{Home: home, Node: "g14", Account: "nox", Run: keygenRunner(calls), State: st,
|
||||||
|
Now: func() time.Time { return time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeeperMakesTheKeyAndWritesItsEntry(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
st, calls := &fakeState{}, &[][]string{}
|
||||||
|
if err := newKeeper(t, home, st, calls).Once(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, ok := st.puts["g14.nox"]
|
||||||
|
if !ok || len(st.puts) != 1 {
|
||||||
|
t.Fatalf("entries = %v, want one under g14.nox", st.puts)
|
||||||
|
}
|
||||||
|
var e map[string]string
|
||||||
|
json.Unmarshal(raw, &e)
|
||||||
|
if e["node"] != "g14" || e["account"] != "nox" || e["kind"] != "machine" || e["at"] != "2026-10-16T12:00:00Z" {
|
||||||
|
t.Errorf("entry = %v", e)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(e["public"], "ssh-ed25519 ") || !strings.HasSuffix(e["public"], " nox@g14 (mesh)") {
|
||||||
|
t.Errorf("public = %q", e["public"])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(e["fingerprint"], "SHA256:") {
|
||||||
|
t.Errorf("fingerprint = %q", e["fingerprint"])
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||||
|
t.Errorf("the entry carries private material: %s", raw)
|
||||||
|
}
|
||||||
|
cmd := strings.Join((*calls)[0], " ")
|
||||||
|
for _, want := range []string{"-t ed25519", "-N ", "-C nox@g14 (mesh)", filepath.Join(home, ".ssh", "mesh_ed25519")} {
|
||||||
|
if !strings.Contains(cmd, want) {
|
||||||
|
t.Errorf("ssh-keygen call %q lacks %q", cmd, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeeperKeepsAnExistingKeyByteForByte(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||||
|
priv, pub := filepath.Join(home, ".ssh", "mesh_ed25519"), filepath.Join(home, ".ssh", "mesh_ed25519.pub")
|
||||||
|
os.WriteFile(priv, []byte("mine"), 0o600)
|
||||||
|
os.WriteFile(pub, []byte(ed25519Line(9, "nox@g14 (mesh)")+"\n"), 0o644)
|
||||||
|
st, calls := &fakeState{}, &[][]string{}
|
||||||
|
k := newKeeper(t, home, st, calls)
|
||||||
|
if err := k.Once(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*calls) != 0 {
|
||||||
|
t.Errorf("ssh-keygen ran over an existing key: %v", *calls)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(priv); string(b) != "mine" {
|
||||||
|
t.Errorf("private file changed: %q", b)
|
||||||
|
}
|
||||||
|
if _, ok := st.puts["g14.nox"]; !ok {
|
||||||
|
t.Errorf("no entry for the existing key")
|
||||||
|
}
|
||||||
|
// A second round with nothing changed writes nothing.
|
||||||
|
delete(st.puts, "g14.nox")
|
||||||
|
k.Once(context.Background())
|
||||||
|
if len(st.puts) != 0 {
|
||||||
|
t.Errorf("an unchanged key was put again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeeperNeverTouchesTheOperatorsOwnKeys(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(home, ".ssh"), 0o700)
|
||||||
|
own := filepath.Join(home, ".ssh", "id_ed25519")
|
||||||
|
os.WriteFile(own, []byte("operator"), 0o600)
|
||||||
|
st, calls := &fakeState{}, &[][]string{}
|
||||||
|
newKeeper(t, home, st, calls).Once(context.Background())
|
||||||
|
if b, _ := os.ReadFile(own); string(b) != "operator" {
|
||||||
|
t.Errorf("the operator's key changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/user"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
go startKeeper()
|
||||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client.
|
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client.
|
||||||
if err := stdio.Serve("", tools(NewClient())); err != nil {
|
if err := stdio.Serve("", tools(NewClient())); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
@@ -136,3 +139,23 @@ func flag(args map[string]any, key string, def bool) bool {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startKeeper runs the forge key's keeper (keykeeper.go) when the runtime launched this process for a node:
|
||||||
|
// outside a launch — a build, a check — there is no state to put the entry in.
|
||||||
|
func startKeeper() {
|
||||||
|
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
|
||||||
|
c := NewClient()
|
||||||
|
u, err := user.Current()
|
||||||
|
if node == "" || err != nil || c.Home == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k := &KeyKeeper{Home: c.Home, Node: node, Account: u.Username, Run: ExecRunner, State: keysState{}, Now: time.Now}
|
||||||
|
k.Keep(context.Background(), func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) })
|
||||||
|
}
|
||||||
|
|
||||||
|
type keysState struct{}
|
||||||
|
|
||||||
|
func (keysState) Put(key string, value any) error {
|
||||||
|
_, err := stdio.State("keys").Put(key, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,3 +63,115 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue 955, area S2: the module keeps the keys state and requires the forge's account provision. It declares
|
||||||
|
// no provision `ssh-key`: the state serves the public half (novox/hq ADR 0400 §1).
|
||||||
|
func TestManifestKeepsKeysAndRequiresForgeAccount(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
State []string `json:"state"`
|
||||||
|
Requires []any `json:"requires"`
|
||||||
|
Provides []any `json:"provides"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(m.State) != 1 || m.State[0] != "keys" {
|
||||||
|
t.Errorf("state = %v, want [keys]", m.State)
|
||||||
|
}
|
||||||
|
if len(m.Requires) != 1 || m.Requires[0] != "forge-account" {
|
||||||
|
t.Errorf("requires = %v, want [forge-account]", m.Requires)
|
||||||
|
}
|
||||||
|
if len(m.Provides) != 0 {
|
||||||
|
t.Errorf("provides = %v, want none (no ssh-key provision yet)", m.Provides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue 955, area S3: the three files the forge account fills in — known_hosts, the forge's Host, git's rewrite.
|
||||||
|
var sampleBound = map[string]string{
|
||||||
|
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
|
||||||
|
"ssh_host": "novox.internal",
|
||||||
|
"ssh_port": "222",
|
||||||
|
"ssh_base": "ssh://git@novox.internal:222/",
|
||||||
|
"https_base": "https://git.novox.be/",
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBound fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
|
||||||
|
func renderBound(t *testing.T, s string) string {
|
||||||
|
t.Helper()
|
||||||
|
for k, v := range sampleBound {
|
||||||
|
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "${") {
|
||||||
|
t.Errorf("unfilled placeholder in %q", s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceByPath(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["path"] == path {
|
||||||
|
if r["owner"] != "${machine:account}" {
|
||||||
|
t.Errorf("%s is owned by %v, not the machine's account", path, r["owner"])
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no resource at %s", path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgeFilesRenderFromTheBoundValues(t *testing.T) {
|
||||||
|
home := "${machine:account-home}"
|
||||||
|
kh := resourceByPath(t, home+"/.ssh/known_hosts")
|
||||||
|
if kh["into"] != "block" {
|
||||||
|
t.Errorf("known_hosts is %v, want a block", kh["into"])
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(renderBound(t, templateText(t, kh))); got != "[novox.internal]:222 ssh-ed25519 AAAAC3Nza" {
|
||||||
|
t.Errorf("known_hosts block = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
forge := resourceByPath(t, home+"/.ssh/config.d/10-forge")
|
||||||
|
if forge["into"] != nil {
|
||||||
|
t.Errorf("10-forge is a block, want the whole file")
|
||||||
|
}
|
||||||
|
want := "Host novox.internal\n Port 222\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
|
||||||
|
if got := renderBound(t, templateText(t, forge)); got != want {
|
||||||
|
t.Errorf("10-forge = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceByPath(t, home+"/.config/git") // the directory
|
||||||
|
g := resourceByPath(t, home+"/.config/git/config")
|
||||||
|
if g["into"] != "block" {
|
||||||
|
t.Errorf("git config is %v, want a block", g["into"])
|
||||||
|
}
|
||||||
|
wantGit := "[url \"ssh://git@novox.internal:222/\"]\n\tinsteadOf = https://git.novox.be/\n"
|
||||||
|
if got := renderBound(t, templateText(t, g)); got != wantGit {
|
||||||
|
t.Errorf("git config block = %q, want %q", got, wantGit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mesh's region in ~/.ssh/config brings in config.d/*, so 10-forge is read.
|
||||||
|
func TestConfigIncludesTheDropInDirectory(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "config"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "Include ~/.ssh/config.d/*") {
|
||||||
|
t.Errorf("files/config does not include config.d/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Host ${bound:forge-account:ssh_host}
|
||||||
|
Port ${bound:forge-account:ssh_port}
|
||||||
|
User git
|
||||||
|
IdentityFile ~/.ssh/mesh_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[url "${bound:forge-account:ssh_base}"]
|
||||||
|
insteadOf = ${bound:forge-account:https_base}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
${bound:forge-account:known_host}
|
||||||
@@ -1,5 +1,22 @@
|
|||||||
module sshclient
|
module sshclient
|
||||||
|
|
||||||
go 1.22
|
go 1.25.0
|
||||||
|
|
||||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
require (
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17
|
||||||
|
github.com/nats-io/nats.go v1.51.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
|
github.com/minio/highwayhash v1.0.4 // indirect
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,2 +1,27 @@
|
|||||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
|
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||||
|
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||||
|
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||||
|
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||||
|
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||||
|
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||||
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"module": "ssh-client",
|
"module": "ssh-client",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
"tools": [
|
"tools": [
|
||||||
"ssh_client_hosts",
|
"ssh_client_hosts",
|
||||||
"ssh_client_resolve",
|
"ssh_client_resolve",
|
||||||
@@ -11,6 +12,12 @@
|
|||||||
"ssh_client_known_host",
|
"ssh_client_known_host",
|
||||||
"ssh_client_test"
|
"ssh_client_test"
|
||||||
],
|
],
|
||||||
|
"requires": [
|
||||||
|
"forge-account"
|
||||||
|
],
|
||||||
|
"state": [
|
||||||
|
"keys"
|
||||||
|
],
|
||||||
"data": {
|
"data": {
|
||||||
"own": [
|
"own": [
|
||||||
{
|
{
|
||||||
@@ -45,6 +52,39 @@
|
|||||||
"into": "block",
|
"into": "block",
|
||||||
"at": "start",
|
"at": "start",
|
||||||
"template": "files/config"
|
"template": "files/config"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "known-hosts",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.ssh/known_hosts",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"into": "block",
|
||||||
|
"template": "files/known_hosts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forge-host",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.ssh/config.d/10-forge",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0600",
|
||||||
|
"template": "files/forge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git-config-dir",
|
||||||
|
"type": "directory",
|
||||||
|
"path": "${machine:account-home}/.config/git",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git-config",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.config/git/config",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"into": "block",
|
||||||
|
"template": "files/gitconfig"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"facts": {
|
"facts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user