`llmgw server init` does what install.md's steps 2 and 3 asked of the operator: the llmgw user and directories, a certificate authority, the control plane's certificate for every address the machine has, a configuration, the default project, an administrator with a generated password, and the systemd unit — then starts it and waits for /health. Every step looks before it acts, so a second run changes nothing, and --host later reissues the certificate with the old names kept. It never overwrites server.yaml or an authority, and leaves alone certificates issued by an authority kept on another machine. The configuration install.md showed could not start: an empty catalog without control.enabled is refused as having nothing to serve. The one init writes sets it, and a test holds that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
610 lines
17 KiB
Go
610 lines
17 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
|
)
|
|
|
|
// serverUnit is deploy/systemd/llmgw-server.service. A test holds the two
|
|
// together, because a machine set up by init and one set up from the docs
|
|
// should not differ in a way nobody chose.
|
|
const serverUnit = `[Unit]
|
|
Description=llmgw control plane
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
ExecStart=/usr/local/bin/llmgw server --config /etc/llmgw/server.yaml
|
|
User=llmgw
|
|
Group=llmgw
|
|
Restart=on-failure
|
|
RestartSec=2s
|
|
NoNewPrivileges=true
|
|
ProtectSystem=strict
|
|
ProtectHome=true
|
|
PrivateTmp=true
|
|
StateDirectory=llmgw
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
|
|
// serverInit is everything a control plane needs before it can start, done in
|
|
// the order it has to be done in. Every step looks before it acts, so running
|
|
// it again finishes an install that stopped halfway and changes nothing on
|
|
// one that did not.
|
|
type serverInit struct {
|
|
etcDir string // /etc/llmgw
|
|
stateDir string // /var/lib/llmgw
|
|
pkiDir string // where the authority lives
|
|
unitPath string // "" leaves systemd alone
|
|
|
|
// user owns the state and reads the key; "" means no user is created and
|
|
// nothing is chowned, which is what a test wants.
|
|
user string
|
|
hosts []string
|
|
admin string
|
|
|
|
out io.Writer
|
|
|
|
uid, gid int
|
|
changed bool
|
|
}
|
|
|
|
func newServerInitCmd() *cobra.Command {
|
|
in := serverInit{
|
|
etcDir: "/etc/llmgw",
|
|
stateDir: "/var/lib/llmgw",
|
|
pkiDir: defaultPKIDir,
|
|
unitPath: "/etc/systemd/system/llmgw-server.service",
|
|
user: "llmgw",
|
|
}
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Set this machine up as a control plane and start it",
|
|
Long: "Creates the llmgw user and its directories, a certificate authority, the\n" +
|
|
"control plane's certificate for every address this machine has, a\n" +
|
|
"configuration, an administrator account, and the systemd unit — then\n" +
|
|
"starts it and waits until it answers.\n\n" +
|
|
"Each step is skipped when its result already exists, so it is safe to run\n" +
|
|
"again: to finish an install that stopped, or with another --host to\n" +
|
|
"reissue the certificate. It never overwrites server.yaml or the authority.\n\n" +
|
|
"The authority's key stays in " + defaultPKIDir + " on this machine. Move it\n" +
|
|
"somewhere safer once the nodes are onboarded if this machine is exposed.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if runtime.GOOS != "linux" {
|
|
return fmt.Errorf("init sets up a Linux machine; this is %s", runtime.GOOS)
|
|
}
|
|
if os.Geteuid() != 0 {
|
|
return errors.New("init creates a user, writes /etc and starts a service: run it as root")
|
|
}
|
|
in.out = cmd.OutOrStdout()
|
|
return in.run(cmd.Context())
|
|
},
|
|
}
|
|
f := cmd.Flags()
|
|
f.StringArrayVar(&in.hosts, "host", nil,
|
|
"an address agents dial besides the ones found on this machine; repeatable")
|
|
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
|
|
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
|
|
return cmd
|
|
}
|
|
|
|
func (in *serverInit) run(ctx context.Context) error {
|
|
steps := []struct {
|
|
name string
|
|
fn func(context.Context) error
|
|
}{
|
|
{"user", in.ensureUser},
|
|
{"directories", in.ensureDirs},
|
|
{"certificates", in.ensureCerts},
|
|
{"configuration", in.ensureConfig},
|
|
{"store", in.ensureStore},
|
|
}
|
|
for _, s := range steps {
|
|
if err := s.fn(ctx); err != nil {
|
|
return fmt.Errorf("%s: %w", s.name, err)
|
|
}
|
|
}
|
|
if in.unitPath == "" {
|
|
return nil
|
|
}
|
|
if err := in.ensureService(ctx); err != nil {
|
|
return fmt.Errorf("service: %w", err)
|
|
}
|
|
in.nextSteps()
|
|
return nil
|
|
}
|
|
|
|
func (in *serverInit) done(format string, a ...any) {
|
|
fmt.Fprintf(in.out, " ok "+format+"\n", a...)
|
|
in.changed = true
|
|
}
|
|
|
|
func (in *serverInit) kept(format string, a ...any) {
|
|
fmt.Fprintf(in.out, " kept "+format+"\n", a...)
|
|
}
|
|
|
|
func (in *serverInit) ensureUser(context.Context) error {
|
|
if in.user == "" {
|
|
return nil
|
|
}
|
|
u, err := user.Lookup(in.user)
|
|
if err != nil {
|
|
var unknown user.UnknownUserError
|
|
if !errors.As(err, &unknown) {
|
|
return err
|
|
}
|
|
out, err := exec.Command("useradd", "--system", "--home", in.stateDir,
|
|
"--shell", "/usr/sbin/nologin", in.user).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("useradd: %v: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
if u, err = user.Lookup(in.user); err != nil {
|
|
return err
|
|
}
|
|
in.done("system user %s", in.user)
|
|
} else {
|
|
in.kept("system user %s", in.user)
|
|
}
|
|
in.uid, _ = strconv.Atoi(u.Uid)
|
|
in.gid, _ = strconv.Atoi(u.Gid)
|
|
return nil
|
|
}
|
|
|
|
func (in *serverInit) ensureDirs(context.Context) error {
|
|
for _, d := range []struct {
|
|
path string
|
|
mode os.FileMode
|
|
owned bool
|
|
}{
|
|
{in.etcDir, 0o755, false},
|
|
{in.pkiDir, 0o700, false},
|
|
{in.stateDir, 0o750, true},
|
|
} {
|
|
if err := os.MkdirAll(d.path, d.mode); err != nil {
|
|
return err
|
|
}
|
|
if d.owned {
|
|
if err := in.chown(d.path, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// chown gives a path to the service user, or only its group when the user
|
|
// should read and not write.
|
|
func (in *serverInit) chown(path string, own bool) error {
|
|
if in.user == "" {
|
|
return nil
|
|
}
|
|
uid := 0
|
|
if own {
|
|
uid = in.uid
|
|
}
|
|
return os.Chown(path, uid, in.gid)
|
|
}
|
|
|
|
func (in *serverInit) ensureCerts(context.Context) error {
|
|
certPath := filepath.Join(in.etcDir, "server.pem")
|
|
_, keyErr := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem"))
|
|
_, caErr := os.Stat(filepath.Join(in.etcDir, "ca.pem"))
|
|
_, certErr := os.Stat(certPath)
|
|
if keyErr != nil && caErr == nil && certErr == nil {
|
|
// Issued by an authority kept elsewhere, which is the arrangement for
|
|
// anyone who will not have the CA key on the control plane. Making a
|
|
// new authority here would replace their certificate with one no node
|
|
// trusts.
|
|
if len(in.hosts) > 0 {
|
|
return fmt.Errorf("%s was issued by an authority that is not in %s; "+
|
|
"reissue it there with the new --host", certPath, in.pkiDir)
|
|
}
|
|
in.kept("certificates in %s, issued elsewhere", in.etcDir)
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
ca *tlsconf.CA
|
|
err error
|
|
)
|
|
if keyErr == nil {
|
|
if ca, err = loadCA(in.pkiDir); err != nil {
|
|
return err
|
|
}
|
|
in.kept("certificate authority in %s", in.pkiDir)
|
|
} else {
|
|
if ca, err = tlsconf.NewCA("llmgw cluster CA"); err != nil {
|
|
return err
|
|
}
|
|
if err := ca.Write(in.pkiDir); err != nil {
|
|
return err
|
|
}
|
|
in.done("certificate authority in %s (ca-key.pem signs every node: back it up)", in.pkiDir)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(in.etcDir, "ca.pem"), ca.PEM, 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
want := in.certHosts()
|
|
if cert, err := readCert(certPath); err == nil {
|
|
have := strings.Split(sanNames(cert), ", ")
|
|
var missing []string
|
|
for _, h := range want {
|
|
if !slices.Contains(have, h) {
|
|
missing = append(missing, h)
|
|
}
|
|
}
|
|
// A certificate from this authority naming every address is left
|
|
// alone. One naming fewer is reissued with the union, so an address
|
|
// that worked yesterday is never dropped by adding one today.
|
|
if len(missing) == 0 && cert.CheckSignatureFrom(ca.Cert) == nil {
|
|
in.kept("server certificate for %s", strings.Join(have, ", "))
|
|
return nil
|
|
}
|
|
for _, h := range have {
|
|
if h != "" && !slices.Contains(want, h) {
|
|
want = append(want, h)
|
|
}
|
|
}
|
|
}
|
|
|
|
name, _ := os.Hostname()
|
|
if name == "" {
|
|
name = "llmgw-control"
|
|
}
|
|
leaf, err := ca.IssueServer(name, want)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := leaf.Write(in.etcDir, "server"); err != nil {
|
|
return err
|
|
}
|
|
// The service runs as llmgw and must read its key; nothing else should.
|
|
keyPath := filepath.Join(in.etcDir, "server-key.pem")
|
|
if err := in.chown(keyPath, false); err != nil {
|
|
return err
|
|
}
|
|
if in.user != "" {
|
|
if err := os.Chmod(keyPath, 0o640); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
in.done("server certificate for %s", strings.Join(want, ", "))
|
|
return nil
|
|
}
|
|
|
|
// certHosts is every address an agent could plausibly dial: the hostname and
|
|
// each address on this machine, plus whatever --host added. Asking the
|
|
// operator to list them is how a certificate ends up naming the one address
|
|
// nobody uses, and that is found out by the first node refusing it.
|
|
func (in *serverInit) certHosts() []string {
|
|
var out []string
|
|
add := func(h string) {
|
|
if h != "" && !slices.Contains(out, h) {
|
|
out = append(out, h)
|
|
}
|
|
}
|
|
for _, h := range in.hosts {
|
|
add(h)
|
|
}
|
|
if name, err := os.Hostname(); err == nil {
|
|
add(name)
|
|
}
|
|
for _, ip := range localAddrs() {
|
|
add(ip.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// localAddrs are this machine's addresses another machine could reach:
|
|
// loopback and link-local are left out.
|
|
func localAddrs() []net.IP {
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []net.IP
|
|
for _, a := range addrs {
|
|
n, ok := a.(*net.IPNet)
|
|
if !ok || !n.IP.IsGlobalUnicast() {
|
|
continue
|
|
}
|
|
out = append(out, n.IP)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (in *serverInit) configPath() string { return filepath.Join(in.etcDir, "server.yaml") }
|
|
|
|
func (in *serverInit) ensureConfig(context.Context) error {
|
|
path := in.configPath()
|
|
if _, err := os.Stat(path); err == nil {
|
|
in.kept("%s (never overwritten)", path)
|
|
} else {
|
|
contact := in.admin
|
|
if contact == "" {
|
|
contact = "platform@example.com"
|
|
}
|
|
body := fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
|
|
filepath.Join(in.stateDir, "llmgw.db"), contact)
|
|
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
|
|
return err
|
|
}
|
|
if err := in.chown(path, false); err != nil {
|
|
return err
|
|
}
|
|
in.done("%s", path)
|
|
}
|
|
|
|
// Checked whether written now or years ago: a configuration that will not
|
|
// load is better found here than as a unit restarting every two seconds.
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// serverConfigTemplate is the smallest file that is not a toy: mutual TLS,
|
|
// authentication, default limits and a price, and a control plane with an
|
|
// empty catalog.
|
|
const serverConfigTemplate = `# Written by 'llmgw server init'. Every key: docs/reference.md
|
|
listen: ":8080"
|
|
admin_listen: "127.0.0.1:9090" # serves pprof, which can dump API keys: keep it local
|
|
agent_listen: ":7443"
|
|
|
|
agent_tls:
|
|
ca: %s/ca.pem
|
|
cert: %s/server.pem
|
|
key: %s/server-key.pem
|
|
|
|
# A control plane with nothing in its catalog yet. Models are added while it
|
|
# runs, with 'llmgw ctl model add' or from /admin/mirror.
|
|
control:
|
|
enabled: true
|
|
|
|
auth:
|
|
enabled: true
|
|
store: %s
|
|
signup_open: true
|
|
# Applied to any key no scope gives a limit to. Without them, a project
|
|
# created without limits is metered by nothing at all.
|
|
default_rpm: 60
|
|
default_tpm: 200000
|
|
# Without a price every request costs zero and no budget ever binds. 1000
|
|
# everywhere turns a budget into a plain token quota.
|
|
default_price:
|
|
prompt_per_1k: 1000
|
|
completion_per_1k: 1000
|
|
|
|
landing:
|
|
contact: "%s"
|
|
|
|
# The engines every node installs for itself. Omitted, each gets the llama.cpp
|
|
# build this release was checked against.
|
|
# engines:
|
|
# llamacpp: b10991
|
|
|
|
# A model mirror, once there is one (docs/install.md, step 3):
|
|
# mirror:
|
|
# api: https://models.internal:8082
|
|
# tls: { ca: /etc/llmgw/ca.pem, cert: /etc/llmgw/server.pem, key: /etc/llmgw/server-key.pem }
|
|
`
|
|
|
|
func (in *serverInit) ensureStore(ctx context.Context) error {
|
|
cfg, err := config.Load(in.configPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
address := cfg.Auth.Store
|
|
if address == "" {
|
|
address = filepath.Join(in.stateDir, "llmgw.db")
|
|
}
|
|
s, err := store.Open(ctx, address)
|
|
if err != nil {
|
|
return fmt.Errorf("opening %s: %w", address, err)
|
|
}
|
|
// Opened as root, so a SQLite file created just now belongs to root and
|
|
// the service could not write it. Given back before anything else runs.
|
|
defer in.chownSQLite(address)
|
|
defer s.Close()
|
|
|
|
// The tree an approved account hangs off. Approving into a project that
|
|
// does not exist succeeds, and the first key that person makes does not.
|
|
if err := s.PutOrg(ctx, store.Org{ID: "default", Name: "default"}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutTeam(ctx, store.Team{ID: "default", OrgID: "default", Name: "default"}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutProject(ctx, store.Project{ID: "default", TeamID: "default", Name: "default"}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if in.admin == "" {
|
|
in.kept("store at %s (no --admin: create an account with 'llmgw users create')", address)
|
|
return nil
|
|
}
|
|
if u, err := s.UserByEmail(ctx, in.admin); err == nil {
|
|
if !u.IsAdmin {
|
|
if err := s.SetAdmin(ctx, u.ID, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
in.kept("administrator %s (%s); its password is unchanged", in.admin, u.ID)
|
|
return nil
|
|
} else if !errors.Is(err, store.ErrNotFound) {
|
|
return err
|
|
}
|
|
|
|
password := randomPassword()
|
|
hash, err := account.HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := randomID("usr")
|
|
now := time.Now()
|
|
if err := s.CreateUser(ctx, store.User{
|
|
ID: id, Email: in.admin, Name: in.admin,
|
|
PasswordHash: hash, State: store.UserPending, CreatedAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.ApproveUser(ctx, id, "default", "init", now); err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetAdmin(ctx, id, true); err != nil {
|
|
return err
|
|
}
|
|
in.done("administrator %s (%s)", in.admin, id)
|
|
fmt.Fprintf(in.out, "\n password: %s\n shown once — sign in and change it from /profile\n\n", password)
|
|
return nil
|
|
}
|
|
|
|
func (in *serverInit) chownSQLite(address string) {
|
|
if strings.Contains(address, "://") || strings.HasPrefix(address, "@") {
|
|
return
|
|
}
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
if _, err := os.Stat(address + suffix); err == nil {
|
|
_ = in.chown(address+suffix, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func randomPassword() string {
|
|
var buf [15]byte
|
|
_, _ = rand.Read(buf[:])
|
|
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
|
|
}
|
|
|
|
func (in *serverInit) ensureService(ctx context.Context) error {
|
|
if _, err := exec.LookPath("systemctl"); err != nil {
|
|
fmt.Fprintf(in.out, " skip no systemd here; start it with:\n"+
|
|
" sudo -u %s llmgw server --config %s\n", in.user, in.configPath())
|
|
return nil
|
|
}
|
|
if b, err := os.ReadFile(in.unitPath); err == nil && string(b) == serverUnit {
|
|
in.kept("%s", in.unitPath)
|
|
} else {
|
|
if err == nil {
|
|
// Somebody's edits. Kept beside it, because the unit is also where
|
|
// people put environment overrides and those are not ours to lose.
|
|
_ = os.WriteFile(in.unitPath+".bak", b, 0o644)
|
|
}
|
|
if err := os.WriteFile(in.unitPath, []byte(serverUnit), 0o644); err != nil {
|
|
return err
|
|
}
|
|
in.done("%s", in.unitPath)
|
|
}
|
|
|
|
systemctl := func(args ...string) error {
|
|
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|
|
if err := systemctl("daemon-reload"); err != nil {
|
|
return err
|
|
}
|
|
active := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", "llmgw-server").Run() == nil
|
|
switch {
|
|
case !active:
|
|
if err := systemctl("enable", "--now", "llmgw-server"); err != nil {
|
|
return err
|
|
}
|
|
in.done("llmgw-server started")
|
|
case in.changed:
|
|
// A certificate or configuration it has not read is one it is not
|
|
// using, and the difference shows up as a node that cannot connect.
|
|
if err := systemctl("restart", "llmgw-server"); err != nil {
|
|
return err
|
|
}
|
|
in.done("llmgw-server restarted to pick up the changes")
|
|
default:
|
|
in.kept("llmgw-server running")
|
|
}
|
|
return in.waitHealthy(ctx)
|
|
}
|
|
|
|
func (in *serverInit) waitHealthy(ctx context.Context) error {
|
|
cfg, err := config.Load(in.configPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, port, err := net.SplitHostPort(cfg.Listen)
|
|
if err != nil {
|
|
return fmt.Errorf("listen %q: %w", cfg.Listen, err)
|
|
}
|
|
url := "http://127.0.0.1:" + port + "/health"
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
in.kept("answering on %s", url)
|
|
return nil
|
|
}
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("not answering on %s after 30s; 'journalctl -u llmgw-server -n 50' says why", url)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (in *serverInit) nextSteps() {
|
|
server := "https://<this machine>:7443"
|
|
if ips := localAddrs(); len(ips) > 0 {
|
|
server = "https://" + net.JoinHostPort(ips[0].String(), "7443")
|
|
}
|
|
fmt.Fprintf(in.out, `
|
|
The control plane is up. Next:
|
|
|
|
llmgw ctl status # no nodes, no models: correct for now
|
|
|
|
# each node, from this machine (it holds the authority):
|
|
deploy/bootstrap/onboard-node.sh --node <name> --addr <node ip> --server %s
|
|
|
|
# then a model:
|
|
llmgw ctl model add -f model.yaml
|
|
|
|
The web UI is on port 8080; /admin once you are signed in as the administrator.
|
|
`, server)
|
|
}
|