Allow creating a network without configuring it in daemon.yml

This commit is contained in:
Brian Picciano 2024-12-17 11:33:19 +01:00
parent 73af69fa04
commit 3111d2ca74
19 changed files with 488 additions and 64 deletions

View File

@ -30,7 +30,7 @@ The requirements for this host are:
* At least 3 directories should be chosen, each of which will be committing at
least 1GB. Ideally these directories should be on different physical disks,
but if that's not possible it's ok. See the Next Steps section.
but if that's not possible it's ok.
* None of the resources being used for this network (the UDP port or storage
locations) should be being used by other networks.

View File

@ -25,8 +25,12 @@ create the signature.
## Releasing
Releases are uploaded to the repository's Releases page, and release naming
follows the conventional semantic versioning system. Each release should be
accompanied by a set of changes which have occurred since the last release,
described both in the `CHANGELOG.md` file and in the description on the Release
itself.
Release artifactes are hosted at `micropelago.net` under
`/isle/releases/<release name>`. An `index.gmi` page should be created in that
directory which includes links to each artifact, as well as a changelog
detailing all new features and fixes included since the previous release.
A link to the new release should be included at `/isle/releases/index.gmi`.
The release shoulld be tagged in the git repo using its release name as well,
with the tag notes linking to the `micropelago.net` page.

View File

@ -11,6 +11,11 @@ go test ./... # Test everything
## Integration Tests
NOTE: before running integration tests you will want to make sure you can [build
Isle][building] in the first place.
[building]: ./building.md
Integration tests are those which require processes or state external to the
test itself. Integration tests are marked using the
`toolkit.MarkIntegrationTest` function, which will cause them to be skipped

View File

@ -6,7 +6,9 @@ import (
"fmt"
"isle/nebula"
"isle/toolkit"
"net"
"net/netip"
"strconv"
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
)
@ -34,14 +36,41 @@ func (f *textUnmarshalerFlag[T, P]) String() string {
func (f *textUnmarshalerFlag[T, P]) Type() string { return "string" }
////////////////////////////////////////////////////////////////////////////////
type (
hostNameFlag = textUnmarshalerFlag[nebula.HostName, *nebula.HostName]
ipNetFlag = textUnmarshalerFlag[nebula.IPNet, *nebula.IPNet]
ipFlag = textUnmarshalerFlag[netip.Addr, *netip.Addr]
)
////////////////////////////////////////////////////////////////////////////////
type (
addrFlagStr string
addrFlag = textUnmarshalerFlag[addrFlagStr, *addrFlagStr]
)
func (f addrFlagStr) MarshalText() ([]byte, error) {
return []byte(f), nil
}
func (f *addrFlagStr) UnmarshalText(b []byte) error {
str := string(b)
_, portStr, err := net.SplitHostPort(str)
if err != nil {
return err
}
if _, err := strconv.ParseUint(portStr, 10, 16); err != nil {
return fmt.Errorf("invalid port %q", portStr)
}
*f = addrFlagStr(str)
return nil
}
////////////////////////////////////////////////////////////////////////////////
type logLevelFlag struct {
mlog.Level
}

View File

@ -3,6 +3,8 @@ package main
import (
"bytes"
"context"
"fmt"
"isle/bootstrap"
"isle/daemon"
"isle/daemon/jsonrpc2"
"isle/toolkit"
@ -15,6 +17,14 @@ import (
"gopkg.in/yaml.v3"
)
func bootstrapNewCreationParams(name, domain string) bootstrap.CreationParams {
return bootstrap.CreationParams{
ID: fmt.Sprintf("%s-%s", name, domain),
Name: name,
Domain: domain,
}
}
type runHarness struct {
ctx context.Context
logger *mlog.Logger
@ -59,10 +69,11 @@ func (h *runHarness) run(t *testing.T, args ...string) error {
)
return doRootCmd(h.ctx, h.logger, &subCmdCtxOpts{
args: args,
stdout: h.stdout,
changeStager: h.changeStager,
daemonRPC: daemonRPCClient,
args: args,
stdout: h.stdout,
changeStager: h.changeStager,
daemonRPC: daemonRPCClient,
bootstrapNewCreationParams: bootstrapNewCreationParams,
})
}

View File

@ -6,10 +6,15 @@ import (
"fmt"
"isle/bootstrap"
"isle/daemon"
"isle/daemon/daecommon"
"isle/daemon/network"
"isle/jsonutil"
"isle/nebula"
"isle/toolkit"
"path/filepath"
"slices"
"strconv"
"strings"
)
var subCmdNetworkCreate = subCmd{
@ -17,8 +22,9 @@ var subCmdNetworkCreate = subCmd{
descr: "Create's a new network, with this host being the first host in that network.",
do: func(ctx subCmdCtx) error {
var (
ipNet ipNetFlag
hostName hostNameFlag
ipNet ipNetFlag
hostName hostNameFlag
vpnPublicAddr addrFlag
)
name := ctx.flags.StringP(
@ -44,6 +50,19 @@ var subCmdNetworkCreate = subCmd{
"Name of this host, which will be the first host in the network",
)
vpnPublicAddrF := ctx.flags.VarPF(
&vpnPublicAddr,
"vpn-public-address",
"",
"Public address (host:port) that this host is publicly available on",
)
storageAllocStrs := ctx.flags.StringArray(
"storage-allocation",
nil,
"Storage allocation on this host, in the form '<capacity-in-gb>@<path>`",
)
ctx, err := ctx.withParsedFlags(&withParsedFlagsOpts{
noNetwork: true,
})
@ -58,14 +77,61 @@ var subCmdNetworkCreate = subCmd{
return errors.New("--name, --domain, --ip-net, and --hostname are required")
}
type storageAlloc struct {
capacity uint64
path string
}
storageAllocs := make([]storageAlloc, len(*storageAllocStrs))
for i, str := range *storageAllocStrs {
capStr, path, ok := strings.Cut(str, "@")
if !ok {
return fmt.Errorf(
"malformed --storage-allocation %q, no '@' found", str,
)
}
capacity, err := strconv.ParseUint(capStr, 10, 64)
if err != nil {
return fmt.Errorf(
"invalid --storage-allocation capacity %q", capStr,
)
}
storageAllocs[i] = storageAlloc{capacity, path}
}
daemonRPC, err := ctx.newDaemonRPC()
if err != nil {
return fmt.Errorf("creating daemon RPC client: %w", err)
}
defer daemonRPC.Close()
var networkConfig *daecommon.NetworkConfig
if vpnPublicAddrF.Changed || len(storageAllocs) > 0 {
networkConfig = toolkit.Ptr(
daecommon.NewNetworkConfig(func(c *daecommon.NetworkConfig) {
c.VPN.PublicAddr = string(vpnPublicAddr.V)
for _, a := range storageAllocs {
c.Storage.Allocations = append(
c.Storage.Allocations,
daecommon.ConfigStorageAllocation{
DataPath: filepath.Join(a.path, "data"),
MetaPath: filepath.Join(a.path, "meta"),
Capacity: int(a.capacity),
},
)
}
}),
)
}
err = daemonRPC.CreateNetwork(
ctx, *name, *domain, ipNet.V, hostName.V,
ctx,
ctx.opts.bootstrapNewCreationParams(*name, *domain),
ipNet.V,
hostName.V,
&daemon.CreateNetworkOpts{Config: networkConfig},
)
if err != nil {
return fmt.Errorf("creating network: %w", err)

View File

@ -5,14 +5,133 @@ import (
"fmt"
"isle/bootstrap"
"isle/daemon"
"isle/daemon/daecommon"
"isle/nebula"
"isle/toolkit"
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNetworkCreate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expect func(*testing.T, *daemon.MockRPC)
flags []string
}{
{
name: "no given config",
expect: func(t *testing.T, daemonRPC *daemon.MockRPC) {
daemonRPC.
On(
"CreateNetwork",
toolkit.MockArg[context.Context](),
bootstrapNewCreationParams("aaa", "a.com"),
nebula.MustParseIPNet(t, "172.16.1.0/24"),
nebula.HostName("foo"),
&daemon.CreateNetworkOpts{},
).
Return(nil).
Once()
},
flags: []string{
"--name=aaa",
"--domain=a.com",
"--ip-net=172.16.1.0/24",
"--hostname=foo",
},
},
{
name: "partially given config",
expect: func(t *testing.T, daemonRPC *daemon.MockRPC) {
networkConfig := daecommon.NewNetworkConfig(func(c *daecommon.NetworkConfig) {
c.VPN.PublicAddr = "1.2.3.4:5"
})
daemonRPC.
On(
"CreateNetwork",
toolkit.MockArg[context.Context](),
bootstrapNewCreationParams("aaa", "a.com"),
nebula.MustParseIPNet(t, "172.16.1.0/24"),
nebula.HostName("foo"),
&daemon.CreateNetworkOpts{
Config: &networkConfig,
},
).
Return(nil).
Once()
},
flags: []string{
"--name=aaa",
"--domain=a.com",
"--ip-net=172.16.1.0/24",
"--hostname=foo",
"--vpn-public-address=1.2.3.4:5",
},
},
{
name: "fully given config",
expect: func(t *testing.T, daemonRPC *daemon.MockRPC) {
networkConfig := daecommon.NewNetworkConfig(func(c *daecommon.NetworkConfig) {
c.VPN.PublicAddr = "1.2.3.4:5"
c.Storage.Allocations = []daecommon.ConfigStorageAllocation{
{
DataPath: "/a/data",
MetaPath: "/a/meta",
Capacity: 100,
},
{
DataPath: "/b/data",
MetaPath: "/b/meta",
Capacity: 200,
},
}
})
daemonRPC.
On(
"CreateNetwork",
toolkit.MockArg[context.Context](),
bootstrapNewCreationParams("aaa", "a.com"),
nebula.MustParseIPNet(t, "172.16.1.0/24"),
nebula.HostName("foo"),
&daemon.CreateNetworkOpts{
Config: &networkConfig,
},
).
Return(nil).
Once()
},
flags: []string{
"--name=aaa",
"--domain=a.com",
"--ip-net=172.16.1.0/24",
"--hostname=foo",
"--vpn-public-address=1.2.3.4:5",
"--storage-allocation=100@/a",
"--storage-allocation=200@/b",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var (
h = newRunHarness(t)
args = append([]string{"network", "create"}, test.flags...)
)
test.expect(t, h.daemonRPC)
assert.NoError(t, h.run(t, args...))
})
}
}
func TestNetworkList(t *testing.T) {
t.Parallel()

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"isle/bootstrap"
"isle/daemon"
"isle/daemon/jsonrpc2"
"isle/jsonutil"
@ -40,6 +41,9 @@ type subCmdCtxOpts struct {
stdout io.Writer
changeStager *changeStager
daemonRPC daemon.RPC
// defaults to bootstrap.NewCreationParams
bootstrapNewCreationParams func(name, domain string) bootstrap.CreationParams
}
func (o *subCmdCtxOpts) withDefaults() *subCmdCtxOpts {
@ -55,6 +59,10 @@ func (o *subCmdCtxOpts) withDefaults() *subCmdCtxOpts {
o.stdout = os.Stdout
}
if o.bootstrapNewCreationParams == nil {
o.bootstrapNewCreationParams = bootstrap.NewCreationParams
}
return o
}

View File

@ -3,7 +3,6 @@ package main
import (
"errors"
"fmt"
"net"
)
var subCmdVPNPublicAddressGet = subCmd{
@ -38,8 +37,11 @@ var subCmdVPNPublicAddressSet = subCmd{
name: "set",
descr: "Set the public address of the host, or overwrite the already configured one",
do: func(ctx subCmdCtx) error {
publicAddr := ctx.flags.String(
"public-addr",
var publicAddr addrFlag
publicAddrF := ctx.flags.VarPF(
&publicAddr,
"to",
"",
"Public address (host:port) that this host is publicly available on",
)
@ -49,10 +51,8 @@ var subCmdVPNPublicAddressSet = subCmd{
return fmt.Errorf("parsing flags: %w", err)
}
if *publicAddr == "" {
if !publicAddrF.Changed {
return errors.New("--public-addr is required")
} else if _, _, err := net.SplitHostPort(*publicAddr); err != nil {
return fmt.Errorf("invalid --public-addr: %w", err)
}
daemonRPC, err := ctx.newDaemonRPC()
@ -66,7 +66,7 @@ var subCmdVPNPublicAddressSet = subCmd{
return fmt.Errorf("getting network config: %w", err)
}
config.VPN.PublicAddr = *publicAddr
config.VPN.PublicAddr = string(publicAddr.V)
return daemonRPC.SetConfig(ctx, config)
},

View File

@ -47,15 +47,15 @@ func (c *rpcClient) CreateNebulaCertificate(ctx context.Context, h1 nebula.HostN
return
}
func (c *rpcClient) CreateNetwork(ctx context.Context, name string, domain string, ipNet nebula.IPNet, hostName nebula.HostName) (err error) {
func (c *rpcClient) CreateNetwork(ctx context.Context, creationParams bootstrap.CreationParams, ipNet nebula.IPNet, hostName nebula.HostName, opts *CreateNetworkOpts) (err error) {
err = c.client.Call(
ctx,
nil,
"CreateNetwork",
name,
domain,
creationParams,
ipNet,
hostName,
opts,
)
return
}

View File

@ -21,7 +21,8 @@ var _ RPC = (*Daemon)(nil)
type joinedNetwork struct {
id string
network.Network
config *daecommon.NetworkConfig
creationParams bootstrap.CreationParams
config *daecommon.NetworkConfig
}
// Daemon implements all methods of the Daemon interface, plus others used
@ -96,7 +97,7 @@ func New(
return nil, fmt.Errorf("loading network %q: %w", id, err)
}
d.networks[id] = joinedNetwork{id, n, networkConfig}
d.networks[id] = joinedNetwork{id, n, creationParams, networkConfig}
}
return d, nil
@ -114,26 +115,37 @@ func New(
// and will have full administrative privileges.
//
// Errors:
// - network.ErrInvalidConfig
// - ErrAlreadyJoined
// - [network.ErrInvalidConfig]
// - [ErrAlreadyJoined]
// - [ErrManagedNetworkConfig] - if `opts.NetworkConfig` is given, but a
// NetworkConfig is also provided in the Daemon's Config.
func (d *Daemon) CreateNetwork(
ctx context.Context,
name, domain string, ipNet nebula.IPNet, hostName nebula.HostName,
creationParams bootstrap.CreationParams,
ipNet nebula.IPNet,
hostName nebula.HostName,
opts *CreateNetworkOpts,
) error {
opts = opts.withDefaults()
d.l.Lock()
defer d.l.Unlock()
var (
creationParams = bootstrap.NewCreationParams(name, domain)
networkConfig = pickNetworkConfig(
networkConfig = pickNetworkConfig(
d.daemonConfig.Networks, creationParams,
)
networkLogger = networkLogger(d.logger, creationParams)
)
if joined, err := alreadyJoined(ctx, d.networks, creationParams); err != nil {
return fmt.Errorf("checking if already joined to network: %w", err)
} else if joined {
if opts.Config != nil {
if networkConfig != nil {
return ErrManagedNetworkConfig
}
networkConfig = opts.Config
}
if alreadyJoined(d.networks, creationParams) {
return ErrAlreadyJoined
}
@ -154,7 +166,7 @@ func (d *Daemon) CreateNetwork(
networkLogger.Info(ctx, "Network created successfully")
d.networks[creationParams.ID] = joinedNetwork{
creationParams.ID, n, networkConfig,
creationParams.ID, n, creationParams, networkConfig,
}
return nil
}
@ -181,9 +193,7 @@ func (d *Daemon) JoinNetwork(
)
)
if joined, err := alreadyJoined(ctx, d.networks, creationParams); err != nil {
return fmt.Errorf("checking if already joined to network: %w", err)
} else if joined {
if alreadyJoined(d.networks, creationParams) {
return ErrAlreadyJoined
}
@ -203,7 +213,9 @@ func (d *Daemon) JoinNetwork(
}
networkLogger.Info(ctx, "Network joined successfully")
d.networks[networkID] = joinedNetwork{networkID, n, networkConfig}
d.networks[networkID] = joinedNetwork{
networkID, n, creationParams, networkConfig,
}
return nil
}

View File

@ -5,6 +5,7 @@ import (
"isle/bootstrap"
"isle/daemon/daecommon"
"isle/daemon/network"
"isle/nebula"
"isle/toolkit"
"testing"
@ -234,6 +235,134 @@ func TestNew(t *testing.T) {
})
}
func TestDaemon_CreateNetwork(t *testing.T) {
t.Run("ErrManagedNetworkConfig", func(t *testing.T) {
var (
networkConfig = daecommon.NewNetworkConfig(nil)
h = newHarness(t, &harnessOpts{
config: daecommon.Config{
Networks: map[string]daecommon.NetworkConfig{
"AAA": networkConfig,
},
},
})
creationParams = bootstrap.NewCreationParams("AAA", "a.com")
ipNet = nebula.MustParseIPNet(t, "172.16.0.0/24")
)
err := h.daemon.CreateNetwork(
h.ctx,
creationParams,
ipNet,
nebula.HostName("foo"),
&CreateNetworkOpts{Config: &networkConfig},
)
assert.ErrorIs(t, err, ErrManagedNetworkConfig)
})
t.Run("ErrAlreadyJoined", func(t *testing.T) {
var (
creationParams = bootstrap.NewCreationParams("AAA", "a.com")
networkConfig = daecommon.NewNetworkConfig(nil)
h = newHarness(t, &harnessOpts{
expectNetworksLoaded: []expectNetworkLoad{{
creationParams, nil, network.NewMockNetwork(t),
}},
expectStoredConfigs: map[string]daecommon.NetworkConfig{
creationParams.ID: networkConfig,
},
})
ipNet = nebula.MustParseIPNet(t, "172.16.0.0/24")
)
err := h.daemon.CreateNetwork(
h.ctx,
bootstrap.NewCreationParams("AAA", "aaa.com"),
ipNet,
nebula.HostName("foo"),
nil,
)
assert.ErrorIs(t, err, ErrAlreadyJoined)
})
t.Run("success/config given", func(t *testing.T) {
networkA := network.NewMockNetwork(t)
networkA.On("Shutdown").Return(nil).Once()
var (
h = newHarness(t, nil)
creationParams = bootstrap.NewCreationParams("AAA", "a.com")
networkConfig = daecommon.NewNetworkConfig(func(c *daecommon.NetworkConfig) {
c.VPN.PublicAddr = "1.2.3.4:50"
})
ipNet = nebula.MustParseIPNet(t, "172.16.0.0/24")
hostName = nebula.HostName("foo")
)
h.networkLoader.
On(
"Create",
toolkit.MockArg[context.Context](),
toolkit.MockArg[*mlog.Logger](),
creationParams,
ipNet,
hostName,
&network.Opts{
Config: &networkConfig,
},
).
Return(networkA, nil).
Once()
err := h.daemon.CreateNetwork(
h.ctx,
creationParams,
ipNet,
hostName,
&CreateNetworkOpts{
Config: &networkConfig,
},
)
assert.NoError(t, err)
assert.Contains(t, h.daemon.networks, creationParams.ID)
})
t.Run("success/no config given", func(t *testing.T) {
networkA := network.NewMockNetwork(t)
networkA.On("Shutdown").Return(nil).Once()
var (
h = newHarness(t, nil)
creationParams = bootstrap.NewCreationParams("AAA", "a.com")
ipNet = nebula.MustParseIPNet(t, "172.16.0.0/24")
hostName = nebula.HostName("foo")
)
h.networkLoader.
On(
"Create",
toolkit.MockArg[context.Context](),
toolkit.MockArg[*mlog.Logger](),
creationParams,
ipNet,
hostName,
&network.Opts{},
).
Return(networkA, nil).
Once()
err := h.daemon.CreateNetwork(
h.ctx,
creationParams,
ipNet,
hostName,
nil,
)
assert.NoError(t, err)
assert.Contains(t, h.daemon.networks, creationParams.ID)
})
}
func TestDaemon_SetConfig(t *testing.T) {
t.Run("success", func(t *testing.T) {
var (

View File

@ -58,22 +58,13 @@ func pickNetwork(
}
func alreadyJoined(
ctx context.Context,
networks map[string]joinedNetwork,
creationParams bootstrap.CreationParams,
) (
bool, error,
) {
for networkID, network := range networks {
existingCreationParams, err := network.GetNetworkCreationParams(ctx)
if err != nil {
return false, fmt.Errorf(
"getting creation params of network %q: %w", networkID, err,
)
} else if existingCreationParams.Conflicts(creationParams) {
return true, nil
networks map[string]joinedNetwork, creationParams bootstrap.CreationParams,
) bool {
for _, network := range networks {
if network.creationParams.Conflicts(creationParams) {
return true
}
}
return false, nil
return false
}

View File

@ -14,16 +14,31 @@ import (
"dev.mediocregopher.com/mediocre-go-lib.git/mlog"
)
// CreateNetworkOpts are optional arguments to the CreateNetwork method. A nil
// value is equivalent to a zero value.
type CreateNetworkOpts struct {
// Config will be used as the NetworkConfig for the new Network, rather than
// picking one provided in the Daemon's Config.
Config *daecommon.NetworkConfig
}
func (o *CreateNetworkOpts) withDefaults() *CreateNetworkOpts {
if o == nil {
o = new(CreateNetworkOpts)
}
return o
}
// RPC defines the methods which the Daemon exposes over RPC (via the RPCHandler
// or HTTPHandler methods). Method documentation can be found on the Daemon
// type.
type RPC interface {
CreateNetwork(
ctx context.Context,
name string,
domain string,
creationParams bootstrap.CreationParams,
ipNet nebula.IPNet,
hostName nebula.HostName,
opts *CreateNetworkOpts,
) error
JoinNetwork(context.Context, network.JoiningBootstrap) error

View File

@ -76,17 +76,17 @@ func (_m *MockRPC) CreateNebulaCertificate(_a0 context.Context, _a1 nebula.HostN
return r0, r1
}
// CreateNetwork provides a mock function with given fields: ctx, name, domain, ipNet, hostName
func (_m *MockRPC) CreateNetwork(ctx context.Context, name string, domain string, ipNet nebula.IPNet, hostName nebula.HostName) error {
ret := _m.Called(ctx, name, domain, ipNet, hostName)
// CreateNetwork provides a mock function with given fields: ctx, creationParams, ipNet, hostName, opts
func (_m *MockRPC) CreateNetwork(ctx context.Context, creationParams bootstrap.CreationParams, ipNet nebula.IPNet, hostName nebula.HostName, opts *CreateNetworkOpts) error {
ret := _m.Called(ctx, creationParams, ipNet, hostName, opts)
if len(ret) == 0 {
panic("no return value specified for CreateNetwork")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, nebula.IPNet, nebula.HostName) error); ok {
r0 = rf(ctx, name, domain, ipNet, hostName)
if rf, ok := ret.Get(0).(func(context.Context, bootstrap.CreationParams, nebula.IPNet, nebula.HostName, *CreateNetworkOpts) error); ok {
r0 = rf(ctx, creationParams, ipNet, hostName, opts)
} else {
r0 = ret.Error(0)
}

View File

@ -4,11 +4,21 @@ import (
"fmt"
"net"
"net/netip"
"testing"
)
// IPNet is the CIDR of a nebula network.
type IPNet net.IPNet
// MustParseIPNet is a test helper for parsing a string into an IPNet.
func MustParseIPNet(t *testing.T, str string) IPNet {
var ipNet IPNet
if err := ipNet.UnmarshalText([]byte(str)); err != nil {
t.Fatal(err)
}
return ipNet
}
// UnmarshalText parses and validates an IPNet from a text string.
func (n *IPNet) UnmarshalText(b []byte) error {
str := string(b)

View File

@ -10,3 +10,8 @@ func IsZero[T any](v T) bool {
var zero T
return reflect.DeepEqual(v, zero)
}
// Ptr returns a pointer to the given value.
func Ptr[T any](v T) *T {
return &v
}

View File

@ -0,0 +1,12 @@
---
type: task
---
# Implement `storage allocation modify` Sub-Command
It should be possible to modify an allocation from the command-line, including
changing its capacity, location, and port numbers.
Location will be the difficult one. It will require shutting down the process,
copying the data from the old location, starting the process back up, and either
removing or renaming the old data to mark it as being a backup.

View File

@ -1,7 +1,5 @@
---
type: task
after:
- code/**
---
# Update Documentation
@ -11,3 +9,13 @@ updated.
Check through all development documentation, especially that surrounding
testing.
Doc changes to included are:
- New page on network configuration, how it can be done via the `daemon.yml`
file or via the command-line (but not both!)
- Rework "Contributing a Lighthouse" so that it doesn't directly mention nebula
or lighthouses at all.
- Remove nebula reference from `daemon.yml` comments.