2024-06-17 18:51:02 +00:00
|
|
|
// Package daemon implements the isle daemon, which is a long-running service
|
2024-07-06 13:36:48 +00:00
|
|
|
// managing all isle background tasks and sub-processes for a single network.
|
2022-10-26 21:21:31 +00:00
|
|
|
package daemon
|
|
|
|
|
|
|
|
import (
|
2024-07-21 15:03:59 +00:00
|
|
|
"bytes"
|
2024-06-17 18:51:02 +00:00
|
|
|
"context"
|
2024-07-21 15:03:59 +00:00
|
|
|
"crypto/rand"
|
2024-07-06 13:36:48 +00:00
|
|
|
"errors"
|
2022-10-26 21:21:31 +00:00
|
|
|
"fmt"
|
|
|
|
"io"
|
2024-07-07 10:44:49 +00:00
|
|
|
"io/fs"
|
2024-06-17 18:51:02 +00:00
|
|
|
"isle/bootstrap"
|
2024-07-14 09:58:39 +00:00
|
|
|
"isle/jsonutil"
|
2024-07-07 18:01:10 +00:00
|
|
|
"isle/nebula"
|
2024-07-13 12:34:06 +00:00
|
|
|
"isle/secrets"
|
2024-07-13 14:31:52 +00:00
|
|
|
"net/netip"
|
2022-10-26 21:21:31 +00:00
|
|
|
"os"
|
2024-07-13 12:34:06 +00:00
|
|
|
"path/filepath"
|
2024-07-06 13:36:48 +00:00
|
|
|
"sync"
|
|
|
|
"time"
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-06-22 15:49:56 +00:00
|
|
|
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
|
2022-10-26 21:21:31 +00:00
|
|
|
)
|
|
|
|
|
2024-07-14 11:33:29 +00:00
|
|
|
// CreateHostOpts are optional parameters to the CreateHost method.
|
|
|
|
type CreateHostOpts struct {
|
2024-07-21 15:03:59 +00:00
|
|
|
// IP address of the new host. An IP address will be randomly chosen if one
|
|
|
|
// is not given here.
|
|
|
|
IP netip.Addr
|
|
|
|
|
2024-07-14 11:33:29 +00:00
|
|
|
// CanCreateHosts indicates that the bootstrap produced by CreateHost should
|
|
|
|
// give the new host the ability to create new hosts as well.
|
|
|
|
CanCreateHosts bool
|
2024-07-22 08:42:25 +00:00
|
|
|
|
|
|
|
// TODO add nebula cert tags
|
2024-07-14 11:33:29 +00:00
|
|
|
}
|
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
// Daemon presents all functionality required for client frontends to interact
|
|
|
|
// with isle, typically via the unix socket.
|
|
|
|
type Daemon interface {
|
|
|
|
|
2024-07-07 18:01:10 +00:00
|
|
|
// CreateNetwork will initialize a new network using the given parameters.
|
|
|
|
// - name: Human-readable name of the network.
|
|
|
|
// - domain: Primary domain name that network services are served under.
|
2024-07-12 13:30:21 +00:00
|
|
|
// - ipNet: An IP subnet, in CIDR form, which will be the overall range of
|
|
|
|
// possible IPs in the network. The first IP in this network range will
|
|
|
|
// become this first host's IP.
|
2024-07-07 18:01:10 +00:00
|
|
|
// - hostName: The name of this first host in the network.
|
|
|
|
//
|
2024-07-14 11:11:18 +00:00
|
|
|
// The daemon on which this is called will become the first host in the
|
|
|
|
// network, and will have full administrative privileges.
|
2024-07-07 18:01:10 +00:00
|
|
|
CreateNetwork(
|
2024-07-12 13:30:21 +00:00
|
|
|
ctx context.Context, name, domain string,
|
|
|
|
ipNet nebula.IPNet,
|
|
|
|
hostName nebula.HostName,
|
2024-07-14 11:11:18 +00:00
|
|
|
) error
|
2024-07-07 18:01:10 +00:00
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
// JoinNetwork joins the Daemon to an existing network using the given
|
|
|
|
// Bootstrap.
|
|
|
|
//
|
|
|
|
// Errors:
|
|
|
|
// - ErrAlreadyJoined
|
2024-07-14 09:58:39 +00:00
|
|
|
JoinNetwork(context.Context, JoiningBootstrap) error
|
2024-07-07 10:44:49 +00:00
|
|
|
|
2024-07-12 14:11:42 +00:00
|
|
|
// GetBootstraps returns the currently active Bootstrap.
|
|
|
|
GetBootstrap(context.Context) (bootstrap.Bootstrap, error)
|
2024-07-12 14:03:37 +00:00
|
|
|
|
2024-07-13 12:34:06 +00:00
|
|
|
// GetGarageClientParams returns a GarageClientParams for the current
|
|
|
|
// network state.
|
|
|
|
GetGarageClientParams(context.Context) (GarageClientParams, error)
|
|
|
|
|
2024-07-12 15:05:39 +00:00
|
|
|
// RemoveHost removes the host of the given name from the network.
|
|
|
|
RemoveHost(context.Context, nebula.HostName) error
|
|
|
|
|
2024-07-13 14:31:52 +00:00
|
|
|
// CreateHost creates a bootstrap for a new host with the given name and IP
|
|
|
|
// address.
|
|
|
|
CreateHost(
|
|
|
|
ctx context.Context,
|
|
|
|
hostName nebula.HostName,
|
2024-07-14 11:33:29 +00:00
|
|
|
opts CreateHostOpts,
|
2024-07-13 14:31:52 +00:00
|
|
|
) (
|
2024-07-14 09:58:39 +00:00
|
|
|
JoiningBootstrap, error,
|
2024-07-13 14:31:52 +00:00
|
|
|
)
|
|
|
|
|
2024-07-13 14:08:13 +00:00
|
|
|
// CreateNebulaCertificate creates and signs a new nebula certficate for an
|
|
|
|
// existing host, given the public key for that host. This is currently
|
|
|
|
// mostly useful for creating certs for mobile devices.
|
|
|
|
//
|
2024-07-22 08:42:25 +00:00
|
|
|
// TODO replace this with CreateHostBootstrap, and the
|
|
|
|
// CreateNebulaCertificate RPC method can just pull cert out of that.
|
|
|
|
//
|
2024-07-13 14:08:13 +00:00
|
|
|
// Errors:
|
|
|
|
// - ErrHostNotFound
|
|
|
|
CreateNebulaCertificate(
|
|
|
|
ctx context.Context,
|
|
|
|
hostName nebula.HostName,
|
|
|
|
hostPubKey nebula.EncryptingPublicKey,
|
|
|
|
) (
|
|
|
|
nebula.Certificate, error,
|
|
|
|
)
|
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
// Shutdown blocks until all resources held or created by the daemon,
|
2024-06-24 16:55:36 +00:00
|
|
|
// including child processes it has started, have been cleaned up.
|
2024-06-17 18:51:02 +00:00
|
|
|
//
|
|
|
|
// If this returns an error then it's possible that child processes are
|
|
|
|
// still running and are no longer managed.
|
2024-06-24 16:55:36 +00:00
|
|
|
Shutdown() error
|
2024-06-17 18:51:02 +00:00
|
|
|
}
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
// Opts are optional parameters which can be passed in when initializing a new
|
|
|
|
// Daemon instance. A nil Opts is equivalent to a zero value.
|
|
|
|
type Opts struct {
|
|
|
|
// Stdout and Stderr are what the associated outputs from child processes
|
|
|
|
// will be directed to.
|
|
|
|
Stdout, Stderr io.Writer
|
2024-06-24 12:45:57 +00:00
|
|
|
|
|
|
|
EnvVars EnvVars // Defaults to that returned by GetEnvVars.
|
2024-06-17 18:51:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (o *Opts) withDefaults() *Opts {
|
|
|
|
if o == nil {
|
|
|
|
o = new(Opts)
|
2022-10-26 21:21:31 +00:00
|
|
|
}
|
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
if o.Stdout == nil {
|
|
|
|
o.Stdout = os.Stdout
|
|
|
|
}
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
if o.Stderr == nil {
|
|
|
|
o.Stderr = os.Stderr
|
2022-10-26 21:21:31 +00:00
|
|
|
}
|
|
|
|
|
2024-06-24 12:45:57 +00:00
|
|
|
if o.EnvVars == (EnvVars{}) {
|
|
|
|
o.EnvVars = GetEnvVars()
|
|
|
|
}
|
|
|
|
|
2024-06-17 18:51:02 +00:00
|
|
|
return o
|
2022-10-26 21:21:31 +00:00
|
|
|
}
|
|
|
|
|
2024-07-06 13:36:48 +00:00
|
|
|
const (
|
2024-07-07 10:44:49 +00:00
|
|
|
daemonStateNoNetwork = iota
|
|
|
|
daemonStateInitializing
|
2024-07-06 13:36:48 +00:00
|
|
|
daemonStateOk
|
|
|
|
daemonStateShutdown
|
|
|
|
)
|
|
|
|
|
2024-06-24 16:55:36 +00:00
|
|
|
type daemon struct {
|
|
|
|
logger *mlog.Logger
|
2024-07-06 13:36:48 +00:00
|
|
|
daemonConfig Config
|
|
|
|
envBinDirPath string
|
|
|
|
opts *Opts
|
|
|
|
|
2024-07-14 10:19:39 +00:00
|
|
|
secretsStore secrets.Store
|
|
|
|
garageAdminToken string
|
2024-07-13 12:34:06 +00:00
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
l sync.RWMutex
|
2024-07-06 13:36:48 +00:00
|
|
|
state int
|
|
|
|
children *Children
|
|
|
|
currBootstrap bootstrap.Bootstrap
|
2024-06-24 16:55:36 +00:00
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
shutdownCh chan struct{}
|
|
|
|
wg sync.WaitGroup
|
2024-06-24 16:55:36 +00:00
|
|
|
}
|
|
|
|
|
2024-07-06 13:36:48 +00:00
|
|
|
// NewDaemon initializes and returns a Daemon instance which will manage all
|
|
|
|
// child processes and state required by the isle service, as well as an HTTP
|
|
|
|
// socket over which RPC calls will be served.
|
|
|
|
//
|
|
|
|
// Inner Children instance(s) will be wrapped such that they will be
|
|
|
|
// automatically shutdown and re-created whenever there's changes in the network
|
|
|
|
// which require the configuration to be changed (e.g. a new nebula lighthouse).
|
|
|
|
// During such an inner restart all methods will return ErrRestarting, except
|
|
|
|
// Shutdown which will block until the currently executing restart is finished
|
|
|
|
// and then shutdown cleanly from there.
|
|
|
|
//
|
|
|
|
// While still starting up the Daemon for the first time all methods will return
|
|
|
|
// ErrInitializing, except Shutdown which will block until initialization is
|
|
|
|
// canceled.
|
|
|
|
func NewDaemon(
|
2024-07-20 09:07:11 +00:00
|
|
|
ctx context.Context,
|
|
|
|
logger *mlog.Logger,
|
|
|
|
daemonConfig Config,
|
|
|
|
envBinDirPath string,
|
|
|
|
opts *Opts,
|
2024-07-07 10:44:49 +00:00
|
|
|
) (
|
|
|
|
Daemon, error,
|
|
|
|
) {
|
|
|
|
var (
|
|
|
|
d = &daemon{
|
2024-07-14 10:19:39 +00:00
|
|
|
logger: logger,
|
|
|
|
daemonConfig: daemonConfig,
|
|
|
|
envBinDirPath: envBinDirPath,
|
|
|
|
opts: opts.withDefaults(),
|
|
|
|
garageAdminToken: randStr(32),
|
|
|
|
shutdownCh: make(chan struct{}),
|
2024-07-07 10:44:49 +00:00
|
|
|
}
|
|
|
|
bootstrapFilePath = bootstrap.StateDirPath(d.opts.EnvVars.StateDirPath)
|
|
|
|
)
|
|
|
|
|
2024-07-12 14:34:56 +00:00
|
|
|
if err := d.opts.EnvVars.init(); err != nil {
|
|
|
|
return nil, fmt.Errorf("initializing daemon directories: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-07-13 12:34:06 +00:00
|
|
|
var (
|
|
|
|
secretsPath = filepath.Join(d.opts.EnvVars.StateDirPath, "secrets")
|
|
|
|
err error
|
|
|
|
)
|
|
|
|
|
|
|
|
if d.secretsStore, err = secrets.NewFSStore(secretsPath); err != nil {
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
"initializing secrets store at %q: %w", secretsPath, err,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2024-07-14 09:58:39 +00:00
|
|
|
var currBootstrap bootstrap.Bootstrap
|
|
|
|
err = jsonutil.LoadFile(&currBootstrap, bootstrapFilePath)
|
2024-07-07 10:44:49 +00:00
|
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
|
|
// daemon has never had a network created or joined
|
|
|
|
} else if err != nil {
|
|
|
|
return nil, fmt.Errorf(
|
|
|
|
"loading bootstrap from %q: %w", bootstrapFilePath, err,
|
|
|
|
)
|
2024-07-20 09:07:11 +00:00
|
|
|
} else if err := d.initialize(ctx, currBootstrap); err != nil {
|
2024-07-07 10:44:49 +00:00
|
|
|
return nil, fmt.Errorf("initializing with bootstrap: %w", err)
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
return d, nil
|
|
|
|
}
|
|
|
|
|
2024-07-21 15:20:48 +00:00
|
|
|
// initialize must be called with d.l write lock held.
|
2024-07-07 18:01:10 +00:00
|
|
|
func (d *daemon) initialize(
|
2024-07-20 09:07:11 +00:00
|
|
|
ctx context.Context, currBootstrap bootstrap.Bootstrap,
|
2024-07-07 18:01:10 +00:00
|
|
|
) error {
|
2024-07-07 10:44:49 +00:00
|
|
|
// we update this Host's data using whatever configuration has been provided
|
|
|
|
// by the daemon config. This way the daemon has the most up-to-date
|
|
|
|
// possible bootstrap. This updated bootstrap will later get updated in
|
|
|
|
// garage as a background daemon task, so other hosts will see it as well.
|
|
|
|
currBootstrap, err := coalesceDaemonConfigAndBootstrap(
|
|
|
|
d.daemonConfig, currBootstrap,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("combining daemon configuration into bootstrap: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = writeBootstrapToStateDir(d.opts.EnvVars.StateDirPath, currBootstrap)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("writing bootstrap to state dir: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
d.currBootstrap = currBootstrap
|
|
|
|
d.state = daemonStateInitializing
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
d.logger.Info(ctx, "Creating child processes")
|
|
|
|
d.children, err = NewChildren(
|
|
|
|
ctx,
|
|
|
|
d.logger.WithNamespace("children"),
|
|
|
|
d.envBinDirPath,
|
|
|
|
d.secretsStore,
|
|
|
|
d.daemonConfig,
|
|
|
|
d.garageAdminToken,
|
|
|
|
currBootstrap,
|
|
|
|
d.opts,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("creating child processes: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
d.logger.Info(ctx, "Child processes created")
|
|
|
|
|
|
|
|
if err := d.postInit(ctx); err != nil {
|
|
|
|
d.logger.Error(ctx, "Post-initialization failed, stopping child processes", err)
|
|
|
|
d.children.Shutdown()
|
|
|
|
return fmt.Errorf("performing post-initialization: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
d.state = daemonStateOk
|
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
d.wg.Add(1)
|
2024-07-06 13:36:48 +00:00
|
|
|
go func() {
|
2024-07-07 10:44:49 +00:00
|
|
|
defer d.wg.Done()
|
|
|
|
<-d.shutdownCh
|
|
|
|
cancel()
|
|
|
|
}()
|
|
|
|
|
|
|
|
d.wg.Add(1)
|
|
|
|
go func() {
|
|
|
|
defer d.wg.Done()
|
2024-07-20 09:07:11 +00:00
|
|
|
d.reloadLoop(ctx)
|
2024-07-07 10:44:49 +00:00
|
|
|
d.logger.Debug(ctx, "Daemon restart loop stopped")
|
2024-07-06 13:36:48 +00:00
|
|
|
}()
|
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
return nil
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
func withCurrBootstrap[Res any](
|
|
|
|
d *daemon, fn func(bootstrap.Bootstrap) (Res, error),
|
2024-07-06 13:36:48 +00:00
|
|
|
) (Res, error) {
|
|
|
|
var zero Res
|
2024-07-07 10:44:49 +00:00
|
|
|
d.l.RLock()
|
|
|
|
defer d.l.RUnlock()
|
|
|
|
|
|
|
|
currBootstrap, state := d.currBootstrap, d.state
|
2024-07-06 13:36:48 +00:00
|
|
|
|
|
|
|
switch state {
|
2024-07-07 10:44:49 +00:00
|
|
|
case daemonStateNoNetwork:
|
|
|
|
return zero, ErrNoNetwork
|
2024-07-06 13:36:48 +00:00
|
|
|
case daemonStateInitializing:
|
|
|
|
return zero, ErrInitializing
|
|
|
|
case daemonStateOk:
|
2024-07-07 10:44:49 +00:00
|
|
|
return fn(currBootstrap)
|
2024-07-06 13:36:48 +00:00
|
|
|
case daemonStateShutdown:
|
|
|
|
return zero, errors.New("already shutdown")
|
|
|
|
default:
|
|
|
|
panic(fmt.Sprintf("unknown state %d", d.state))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
// reload will check the existing hosts data from currBootstrap against a
|
|
|
|
// potentially updated set of hosts data, and if there are any differences will
|
|
|
|
// perform whatever changes are necessary.
|
2024-07-19 18:49:04 +00:00
|
|
|
func (d *daemon) reload(
|
2024-07-20 10:36:21 +00:00
|
|
|
ctx context.Context,
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
newHosts map[nebula.HostName]bootstrap.Host,
|
2024-07-19 18:49:04 +00:00
|
|
|
) error {
|
|
|
|
var (
|
2024-07-20 10:36:21 +00:00
|
|
|
newBootstrap = currBootstrap
|
|
|
|
thisHost = currBootstrap.ThisHost()
|
2024-07-19 18:49:04 +00:00
|
|
|
)
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
newBootstrap.Hosts = newHosts
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-07-06 13:36:48 +00:00
|
|
|
// the daemon's view of this host's bootstrap info takes precedence over
|
|
|
|
// whatever is in garage
|
2024-07-19 18:49:04 +00:00
|
|
|
newBootstrap.Hosts[thisHost.Name] = thisHost
|
2024-07-06 13:36:48 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
diff, err := calcBootstrapDiff(d.daemonConfig, currBootstrap, newBootstrap)
|
2024-06-24 16:55:36 +00:00
|
|
|
if err != nil {
|
2024-07-19 18:49:04 +00:00
|
|
|
return fmt.Errorf("calculating diff between bootstraps: %w", err)
|
|
|
|
} else if diff == (bootstrapDiff{}) {
|
2024-07-20 10:36:21 +00:00
|
|
|
d.logger.Info(ctx, "No changes to bootstrap detected")
|
2024-07-19 18:49:04 +00:00
|
|
|
return nil
|
2024-06-24 16:55:36 +00:00
|
|
|
}
|
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
d.logger.Info(ctx, "Bootstrap has changed, storing new bootstrap")
|
|
|
|
d.l.Lock()
|
|
|
|
d.currBootstrap = newBootstrap
|
|
|
|
d.l.Unlock()
|
2024-07-06 13:36:48 +00:00
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
var errs []error
|
2024-07-06 13:36:48 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
// TODO each of these changed cases should block until its respective
|
|
|
|
// service is confirmed to have come back online.
|
|
|
|
|
|
|
|
// TODO it's possible that reload could be called concurrently, and one call
|
|
|
|
// would override the reloading done by the other.
|
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
if diff.dnsChanged {
|
|
|
|
d.logger.Info(ctx, "Restarting dnsmasq to account for bootstrap changes")
|
|
|
|
if err := d.children.RestartDNSMasq(newBootstrap); err != nil {
|
|
|
|
errs = append(errs, fmt.Errorf("restarting dnsmasq: %w", err))
|
|
|
|
}
|
|
|
|
}
|
2024-07-06 13:36:48 +00:00
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
if diff.nebulaChanged {
|
|
|
|
d.logger.Info(ctx, "Restarting nebula to account for bootstrap changes")
|
|
|
|
if err := d.children.RestartNebula(newBootstrap); err != nil {
|
|
|
|
errs = append(errs, fmt.Errorf("restarting nebula: %w", err))
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
|
|
|
}
|
2024-07-19 18:49:04 +00:00
|
|
|
|
|
|
|
return errors.Join(errs...)
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
func (d *daemon) postInit(ctx context.Context) error {
|
2024-07-07 18:01:10 +00:00
|
|
|
if len(d.daemonConfig.Storage.Allocations) > 0 {
|
2024-07-20 09:07:11 +00:00
|
|
|
d.logger.Info(ctx, "Applying garage layout")
|
|
|
|
if err := garageApplyLayout(
|
|
|
|
ctx, d.logger, d.daemonConfig, d.garageAdminToken, d.currBootstrap,
|
|
|
|
); err != nil {
|
|
|
|
return fmt.Errorf("applying garage layout: %w", err)
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This is only necessary during network creation, otherwise the bootstrap
|
|
|
|
// should already have these credentials built in.
|
|
|
|
//
|
|
|
|
// TODO this is pretty hacky, but there doesn't seem to be a better way to
|
|
|
|
// manage it at the moment.
|
2024-07-14 10:19:39 +00:00
|
|
|
_, err := getGarageS3APIGlobalBucketCredentials(ctx, d.secretsStore)
|
|
|
|
if errors.Is(err, secrets.ErrNotFound) {
|
2024-07-20 09:07:11 +00:00
|
|
|
d.logger.Info(ctx, "Initializing garage shared global bucket")
|
|
|
|
garageGlobalBucketCreds, err := garageInitializeGlobalBucket(
|
2024-07-07 18:01:10 +00:00
|
|
|
ctx,
|
|
|
|
d.logger,
|
2024-07-06 13:36:48 +00:00
|
|
|
d.daemonConfig,
|
2024-07-14 10:19:39 +00:00
|
|
|
d.garageAdminToken,
|
2024-07-06 13:36:48 +00:00
|
|
|
d.currBootstrap,
|
|
|
|
)
|
2024-07-20 09:07:11 +00:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("initializing global bucket: %w", err)
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
err = setGarageS3APIGlobalBucketCredentials(
|
|
|
|
ctx, d.secretsStore, garageGlobalBucketCreds,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("storing global bucket creds: %w", err)
|
2022-10-26 21:21:31 +00:00
|
|
|
}
|
2024-07-20 09:07:11 +00:00
|
|
|
}
|
2024-06-17 18:51:02 +00:00
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
d.logger.Info(ctx, "Updating host info in garage")
|
2024-07-20 10:36:21 +00:00
|
|
|
err = d.putGarageBoostrapHost(ctx, d.currBootstrap)
|
2024-07-20 09:07:11 +00:00
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("updating host info in garage: %w", err)
|
2024-07-19 18:49:04 +00:00
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *daemon) reloadLoop(ctx context.Context) {
|
2024-07-19 18:49:04 +00:00
|
|
|
ticker := time.NewTicker(3 * time.Minute)
|
|
|
|
defer ticker.Stop()
|
2024-07-07 18:01:10 +00:00
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-ctx.Done():
|
2024-07-06 13:36:48 +00:00
|
|
|
return
|
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
case <-ticker.C:
|
2024-07-20 10:36:21 +00:00
|
|
|
d.l.RLock()
|
|
|
|
currBootstrap := d.currBootstrap
|
|
|
|
d.l.RUnlock()
|
2024-07-06 13:36:48 +00:00
|
|
|
|
2024-07-19 18:49:04 +00:00
|
|
|
d.logger.Info(ctx, "Checking for bootstrap changes")
|
2024-07-20 10:36:21 +00:00
|
|
|
newHosts, err := d.getGarageBootstrapHosts(ctx, currBootstrap)
|
2024-07-19 18:49:04 +00:00
|
|
|
if err != nil {
|
|
|
|
d.logger.Error(ctx, "Failed to get hosts from garage", err)
|
|
|
|
continue
|
|
|
|
}
|
2024-07-07 18:01:10 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
// TODO there's some potential race conditions here, where
|
|
|
|
// CreateHost could be called at this point, write the new host to
|
|
|
|
// garage and the bootstrap, but then this reload call removes the
|
|
|
|
// host from this bootstrap/children until the next reload.
|
|
|
|
|
|
|
|
if err := d.reload(ctx, currBootstrap, newHosts); err != nil {
|
2024-07-19 18:49:04 +00:00
|
|
|
d.logger.Error(ctx, "Reloading with new host data failed", err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *daemon) CreateNetwork(
|
|
|
|
ctx context.Context,
|
2024-07-12 13:30:21 +00:00
|
|
|
name, domain string, ipNet nebula.IPNet, hostName nebula.HostName,
|
2024-07-14 11:11:18 +00:00
|
|
|
) error {
|
2024-07-12 13:30:21 +00:00
|
|
|
nebulaCACreds, err := nebula.NewCACredentials(domain, ipNet)
|
2024-07-07 18:01:10 +00:00
|
|
|
if err != nil {
|
2024-07-14 11:11:18 +00:00
|
|
|
return fmt.Errorf("creating nebula CA cert: %w", err)
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
|
2024-07-13 12:34:06 +00:00
|
|
|
var (
|
2024-07-14 11:11:18 +00:00
|
|
|
creationParams = bootstrap.CreationParams{
|
|
|
|
ID: randStr(32),
|
|
|
|
Name: name,
|
|
|
|
Domain: domain,
|
2024-07-13 12:34:06 +00:00
|
|
|
}
|
|
|
|
|
2024-07-14 10:19:39 +00:00
|
|
|
garageRPCSecret = randStr(32)
|
2024-07-13 12:34:06 +00:00
|
|
|
)
|
|
|
|
|
2024-07-14 09:58:39 +00:00
|
|
|
err = setGarageRPCSecret(ctx, d.secretsStore, garageRPCSecret)
|
2024-07-13 12:34:06 +00:00
|
|
|
if err != nil {
|
2024-07-14 11:11:18 +00:00
|
|
|
return fmt.Errorf("setting garage RPC secret: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
err = setNebulaCASigningPrivateKey(ctx, d.secretsStore, nebulaCACreds.SigningPrivateKey)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("setting nebula CA signing key secret: %w", err)
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
hostBootstrap, err := bootstrap.New(
|
|
|
|
nebulaCACreds,
|
2024-07-14 11:11:18 +00:00
|
|
|
creationParams,
|
2024-07-20 10:36:21 +00:00
|
|
|
map[nebula.HostName]bootstrap.Host{},
|
2024-07-07 18:01:10 +00:00
|
|
|
hostName,
|
2024-07-12 13:30:21 +00:00
|
|
|
ipNet.FirstAddr(),
|
2024-07-07 18:01:10 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2024-07-14 11:11:18 +00:00
|
|
|
return fmt.Errorf("initializing bootstrap data: %w", err)
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
d.l.Lock()
|
2024-07-21 15:20:48 +00:00
|
|
|
defer d.l.Unlock()
|
2024-07-07 18:01:10 +00:00
|
|
|
|
|
|
|
if d.state != daemonStateNoNetwork {
|
2024-07-14 11:11:18 +00:00
|
|
|
return ErrAlreadyJoined
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if len(d.daemonConfig.Storage.Allocations) < 3 {
|
2024-07-14 11:11:18 +00:00
|
|
|
return ErrInvalidConfig.WithData(
|
2024-07-07 18:01:10 +00:00
|
|
|
"At least three storage allocations are required.",
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
// initialize will unlock d.l
|
2024-07-21 15:20:48 +00:00
|
|
|
if err = d.initialize(ctx, hostBootstrap); err != nil {
|
2024-07-14 11:11:18 +00:00
|
|
|
return fmt.Errorf("initializing daemon: %w", err)
|
2024-07-07 18:01:10 +00:00
|
|
|
}
|
|
|
|
|
2024-07-14 11:11:18 +00:00
|
|
|
return nil
|
2024-07-06 13:36:48 +00:00
|
|
|
}
|
2022-10-26 21:21:31 +00:00
|
|
|
|
2024-07-07 10:44:49 +00:00
|
|
|
func (d *daemon) JoinNetwork(
|
2024-07-14 09:58:39 +00:00
|
|
|
ctx context.Context, newBootstrap JoiningBootstrap,
|
2024-07-07 10:44:49 +00:00
|
|
|
) error {
|
|
|
|
d.l.Lock()
|
2024-07-21 15:20:48 +00:00
|
|
|
defer d.l.Unlock()
|
2024-07-07 10:44:49 +00:00
|
|
|
|
|
|
|
if d.state != daemonStateNoNetwork {
|
|
|
|
return ErrAlreadyJoined
|
|
|
|
}
|
|
|
|
|
2024-07-14 09:58:39 +00:00
|
|
|
err := secrets.Import(ctx, d.secretsStore, newBootstrap.Secrets)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("importing secrets: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
// initialize will unlock d.l
|
2024-07-21 15:20:48 +00:00
|
|
|
if err = d.initialize(ctx, newBootstrap.Bootstrap); err != nil {
|
2024-07-07 18:01:10 +00:00
|
|
|
return fmt.Errorf("initializing daemon: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-07-20 09:07:11 +00:00
|
|
|
return nil
|
2024-07-07 10:44:49 +00:00
|
|
|
}
|
|
|
|
|
2024-07-12 14:11:42 +00:00
|
|
|
func (d *daemon) GetBootstrap(ctx context.Context) (bootstrap.Bootstrap, error) {
|
2024-07-12 14:03:37 +00:00
|
|
|
return withCurrBootstrap(d, func(
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
) (
|
2024-07-12 14:11:42 +00:00
|
|
|
bootstrap.Bootstrap, error,
|
2024-07-12 14:03:37 +00:00
|
|
|
) {
|
2024-07-12 14:11:42 +00:00
|
|
|
return currBootstrap, nil
|
2024-07-12 14:03:37 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-07-13 12:34:06 +00:00
|
|
|
func (d *daemon) GetGarageClientParams(
|
|
|
|
ctx context.Context,
|
|
|
|
) (
|
|
|
|
GarageClientParams, error,
|
|
|
|
) {
|
|
|
|
return withCurrBootstrap(d, func(
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
) (
|
|
|
|
GarageClientParams, error,
|
|
|
|
) {
|
|
|
|
return d.getGarageClientParams(ctx, currBootstrap)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-07-12 15:05:39 +00:00
|
|
|
func (d *daemon) RemoveHost(ctx context.Context, hostName nebula.HostName) error {
|
|
|
|
// TODO RemoveHost should publish a certificate revocation for the host
|
|
|
|
// being removed.
|
|
|
|
_, err := withCurrBootstrap(d, func(
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
) (
|
|
|
|
struct{}, error,
|
|
|
|
) {
|
2024-07-13 12:34:06 +00:00
|
|
|
garageClientParams, err := d.getGarageClientParams(ctx, currBootstrap)
|
|
|
|
if err != nil {
|
|
|
|
return struct{}{}, fmt.Errorf("get garage client params: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
client := garageClientParams.GlobalBucketS3APIClient()
|
2024-07-12 15:05:39 +00:00
|
|
|
return struct{}{}, removeGarageBootstrapHost(ctx, client, hostName)
|
|
|
|
})
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2024-07-13 14:08:13 +00:00
|
|
|
func makeCACreds(
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
caSigningPrivateKey nebula.SigningPrivateKey,
|
|
|
|
) nebula.CACredentials {
|
|
|
|
return nebula.CACredentials{
|
|
|
|
Public: currBootstrap.CAPublicCredentials,
|
|
|
|
SigningPrivateKey: caSigningPrivateKey,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-07-21 15:03:59 +00:00
|
|
|
func chooseAvailableIP(b bootstrap.Bootstrap) (netip.Addr, error) {
|
|
|
|
var (
|
|
|
|
cidrIPNet = b.CAPublicCredentials.Cert.Unwrap().Details.Subnets[0]
|
|
|
|
cidrMask = cidrIPNet.Mask
|
|
|
|
cidrIPB = cidrIPNet.IP
|
|
|
|
|
|
|
|
cidr = netip.MustParsePrefix(cidrIPNet.String())
|
|
|
|
cidrIP = cidr.Addr()
|
|
|
|
cidrSuffixBits = cidrIP.BitLen() - cidr.Bits()
|
|
|
|
|
|
|
|
inUseIPs = make(map[netip.Addr]struct{}, len(b.Hosts))
|
|
|
|
)
|
|
|
|
|
|
|
|
for _, host := range b.Hosts {
|
|
|
|
inUseIPs[host.IP()] = struct{}{}
|
|
|
|
}
|
|
|
|
|
|
|
|
// first check that there are any addresses at all. We can determine the
|
|
|
|
// number of possible addresses using the network CIDR. The first IP in a
|
|
|
|
// subnet is the network identifier, and is reserved. The last IP is the
|
|
|
|
// broadcast IP, and is also reserved. Hence, the -2.
|
|
|
|
usableIPs := (1 << cidrSuffixBits) - 2
|
|
|
|
if len(inUseIPs) >= usableIPs {
|
|
|
|
return netip.Addr{}, errors.New("no available IPs")
|
|
|
|
}
|
|
|
|
|
|
|
|
// We need to know the subnet broadcast address, so we don't accidentally
|
|
|
|
// produce it.
|
|
|
|
cidrBCastIPB := bytes.Clone(cidrIPB)
|
|
|
|
for i := range cidrBCastIPB {
|
|
|
|
cidrBCastIPB[i] |= ^cidrMask[i]
|
|
|
|
}
|
|
|
|
cidrBCastIP, ok := netip.AddrFromSlice(cidrBCastIPB)
|
|
|
|
if !ok {
|
|
|
|
panic(fmt.Sprintf("invalid broadcast ip calculated: %x", cidrBCastIP))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try a handful of times to pick an IP at random. This is preferred, as it
|
|
|
|
// leaves less room for two different CreateHost calls to choose the same
|
|
|
|
// IP.
|
|
|
|
for range 20 {
|
|
|
|
b := make([]byte, len(cidrIPB))
|
|
|
|
if _, err := rand.Read(b); err != nil {
|
|
|
|
return netip.Addr{}, fmt.Errorf("reading random bytes: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
for i := range b {
|
|
|
|
b[i] = cidrIPB[i] | (b[i] & ^cidrMask[i])
|
|
|
|
}
|
|
|
|
|
|
|
|
ip, ok := netip.AddrFromSlice(b)
|
|
|
|
if !ok {
|
|
|
|
panic(fmt.Sprintf("generated invalid IP: %x", b))
|
|
|
|
} else if !cidr.Contains(ip) {
|
|
|
|
panic(fmt.Sprintf(
|
|
|
|
"generated IP %v which is not in cidr %v", ip, cidr,
|
|
|
|
))
|
|
|
|
}
|
|
|
|
|
|
|
|
if ip == cidrIP || ip == cidrBCastIP {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, inUse := inUseIPs[ip]; !inUse {
|
|
|
|
return ip, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If randomly picking fails then just go through IPs one by one until the
|
|
|
|
// free one is found.
|
|
|
|
for ip := cidrIP.Next(); ip != cidrBCastIP; ip = ip.Next() {
|
|
|
|
if _, inUse := inUseIPs[ip]; !inUse {
|
|
|
|
return ip, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
panic("All ips are in-use, but somehow that wasn't determined earlier")
|
|
|
|
}
|
|
|
|
|
2024-07-13 14:31:52 +00:00
|
|
|
func (d *daemon) CreateHost(
|
|
|
|
ctx context.Context,
|
|
|
|
hostName nebula.HostName,
|
2024-07-14 11:33:29 +00:00
|
|
|
opts CreateHostOpts,
|
2024-07-13 14:31:52 +00:00
|
|
|
) (
|
2024-07-14 09:58:39 +00:00
|
|
|
JoiningBootstrap, error,
|
2024-07-13 14:31:52 +00:00
|
|
|
) {
|
2024-07-20 10:36:21 +00:00
|
|
|
d.l.RLock()
|
|
|
|
currBootstrap := d.currBootstrap
|
|
|
|
d.l.RUnlock()
|
|
|
|
|
2024-07-21 15:03:59 +00:00
|
|
|
ip := opts.IP
|
|
|
|
if ip == (netip.Addr{}) {
|
|
|
|
var err error
|
|
|
|
if ip, err = chooseAvailableIP(currBootstrap); err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf(
|
|
|
|
"choosing available IP: %w", err,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2024-07-22 08:42:25 +00:00
|
|
|
// TODO if the ip is given, check that it's not already in use.
|
2024-07-21 15:03:59 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
caSigningPrivateKey, err := getNebulaCASigningPrivateKey(
|
|
|
|
ctx, d.secretsStore,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf("getting CA signing key: %w", err)
|
|
|
|
}
|
2024-07-13 14:31:52 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
var joiningBootstrap JoiningBootstrap
|
|
|
|
joiningBootstrap.Bootstrap, err = bootstrap.New(
|
|
|
|
makeCACreds(currBootstrap, caSigningPrivateKey),
|
|
|
|
currBootstrap.NetworkCreationParams,
|
|
|
|
currBootstrap.Hosts,
|
|
|
|
hostName,
|
|
|
|
ip,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf(
|
|
|
|
"initializing bootstrap data: %w", err,
|
2024-07-13 14:31:52 +00:00
|
|
|
)
|
2024-07-20 10:36:21 +00:00
|
|
|
}
|
2024-07-13 14:31:52 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
secretsIDs := []secrets.ID{
|
|
|
|
garageRPCSecretSecretID,
|
|
|
|
garageS3APIGlobalBucketCredentialsSecretID,
|
|
|
|
}
|
2024-07-14 09:58:39 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
if opts.CanCreateHosts {
|
|
|
|
secretsIDs = append(secretsIDs, nebulaCASigningPrivateKeySecretID)
|
|
|
|
}
|
2024-07-14 11:33:29 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
if joiningBootstrap.Secrets, err = secrets.Export(
|
|
|
|
ctx, d.secretsStore, secretsIDs,
|
|
|
|
); err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf("exporting secrets: %w", err)
|
|
|
|
}
|
2024-07-14 11:33:29 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
d.logger.Info(ctx, "Putting new host in garage")
|
|
|
|
err = d.putGarageBoostrapHost(ctx, joiningBootstrap.Bootstrap)
|
|
|
|
if err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf("putting new host in garage: %w", err)
|
|
|
|
}
|
2024-07-13 14:31:52 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
// the new bootstrap will have been initialized with both all existing hosts
|
|
|
|
// (based on currBootstrap) and the host being created.
|
|
|
|
newHosts := joiningBootstrap.Bootstrap.Hosts
|
2024-07-13 14:31:52 +00:00
|
|
|
|
2024-07-20 10:36:21 +00:00
|
|
|
d.logger.Info(ctx, "Reloading local state with new host")
|
|
|
|
if err := d.reload(ctx, currBootstrap, newHosts); err != nil {
|
|
|
|
return JoiningBootstrap{}, fmt.Errorf("reloading child processes: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return joiningBootstrap, nil
|
2024-07-13 14:31:52 +00:00
|
|
|
}
|
|
|
|
|
2024-07-13 14:08:13 +00:00
|
|
|
func (d *daemon) CreateNebulaCertificate(
|
|
|
|
ctx context.Context,
|
|
|
|
hostName nebula.HostName,
|
|
|
|
hostPubKey nebula.EncryptingPublicKey,
|
|
|
|
) (
|
|
|
|
nebula.Certificate, error,
|
|
|
|
) {
|
|
|
|
return withCurrBootstrap(d, func(
|
|
|
|
currBootstrap bootstrap.Bootstrap,
|
|
|
|
) (
|
|
|
|
nebula.Certificate, error,
|
|
|
|
) {
|
2024-07-21 15:06:27 +00:00
|
|
|
host, ok := currBootstrap.Hosts[hostName]
|
|
|
|
if !ok {
|
|
|
|
return nebula.Certificate{}, ErrHostNotFound
|
2024-07-13 14:08:13 +00:00
|
|
|
}
|
2024-07-21 15:06:27 +00:00
|
|
|
ip := host.IP()
|
2024-07-13 14:08:13 +00:00
|
|
|
|
2024-07-14 11:11:18 +00:00
|
|
|
caSigningPrivateKey, err := getNebulaCASigningPrivateKey(
|
|
|
|
ctx, d.secretsStore,
|
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return nebula.Certificate{}, fmt.Errorf("getting CA signing key: %w", err)
|
|
|
|
}
|
|
|
|
|
2024-07-13 14:08:13 +00:00
|
|
|
caCreds := makeCACreds(currBootstrap, caSigningPrivateKey)
|
|
|
|
|
2024-07-14 12:43:17 +00:00
|
|
|
return nebula.NewHostCert(caCreds, hostPubKey, hostName, ip)
|
2024-07-13 14:08:13 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-06-24 16:55:36 +00:00
|
|
|
func (d *daemon) Shutdown() error {
|
2024-07-07 10:44:49 +00:00
|
|
|
d.l.Lock()
|
|
|
|
defer d.l.Unlock()
|
|
|
|
|
|
|
|
close(d.shutdownCh)
|
|
|
|
d.wg.Wait()
|
|
|
|
d.state = daemonStateShutdown
|
|
|
|
|
|
|
|
if d.children != nil {
|
2024-07-20 09:07:11 +00:00
|
|
|
d.children.Shutdown()
|
2024-07-07 10:44:49 +00:00
|
|
|
}
|
|
|
|
|
2024-06-24 16:55:36 +00:00
|
|
|
return nil
|
2022-10-26 21:21:31 +00:00
|
|
|
}
|