Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ func newInitCmd() *cobra.Command {
GroupID: "init",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if opts.connect == "" && (len(opts.addresses) != 0 || len(opts.prefixes) != 0) {
return errors.New("--address and --prefix are only used with --connect")
}
cmd.SilenceUsage = true
if opts.serve {
return runInitServe(cmd, opts)
Expand Down Expand Up @@ -90,8 +93,8 @@ func newInitCmd() *cobra.Command {
flags.StringVar(&opts.connect, "connect", "", "Join the network through a node running --serve (host[:port])")
flags.StringVar(&opts.token, "token", "", "Setup token printed by --serve")
flags.StringVarP(&opts.central, "config", "c", DefaultConfigPath, "Central config path used by --serve and --connect")
flags.StringSliceVar(&opts.addresses, "address", nil, "Nylon address for this node in central config (repeatable)")
flags.StringSliceVar(&opts.prefixes, "prefix", nil, "IP prefix this node advertises in central config (repeatable)")
flags.StringSliceVar(&opts.addresses, "address", nil, "Nylon address to join with, used by --connect (repeatable)")
flags.StringSliceVar(&opts.prefixes, "prefix", nil, "IP prefix to advertise, used by --connect (repeatable)")
cmd.MarkFlagsMutuallyExclusive("serve", "connect")
cmd.MarkFlagsRequiredTogether("connect", "token")
return cmd
Expand Down
105 changes: 27 additions & 78 deletions cmd/init_enrol.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,15 @@ func tokenKey(token string) []byte {

func runInitServe(cmd *cobra.Command, opts initOptions) error {
out := cmd.OutOrStdout()
node, err := loadOrInitNode(cmd, opts)
node, central, err := loadServeConfigs(opts)
if err != nil {
return err
}
central, existed, err := loadOrInitCentral(opts, node)
if err != nil {
return err
}
// an existing network keeps its central config until the join is distributed
outPath := opts.central
if existed {
outPath = joinConfigPath(opts.central)
if !opts.force {
if err = checkPendingJoin(outPath, central); err != nil {
return err
}
// central config stays in sync with the network until the join is distributed
outPath := joinConfigPath(opts.central)
if !opts.force {
if err = checkPendingJoin(outPath, central); err != nil {
return err
}
}

Expand Down Expand Up @@ -129,7 +122,7 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error {
http.Error(w, "setup token already used", http.StatusForbidden)
return
}
updated, err := enrolNode(central, node, req, r.Host)
updated, err := enrolNode(central, req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
Expand Down Expand Up @@ -164,76 +157,40 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error {

fmt.Fprintf(out, "\nNode %s joined with address %v, wrote %s\nIt has no connections yet, add it to graph (e.g. `%s, %s`) then:\n",
req.Id, central.GetNode(req.Id).Addresses, outPath, node.Id, req.Id)
switch {
case !existed:
fmt.Fprintln(out, "Start nylon")
case central.Dist != nil:
if central.Dist != nil {
fmt.Fprintf(out, "Seal it and publish it to %s:\n\n nylon seal -c %s -k %s -o %s\n",
strings.Join(central.Dist.Repos, ", "), outPath, DefaultKeyPath, DefaultBundlePath)
default:
} else {
fmt.Fprintf(out, "Copy it to every node (including this one) as %s and run `nylon reload`\n", filepath.Base(opts.central))
}
return nil
}

// reuse this node's config when present so --serve works on an existing network
func loadOrInitNode(cmd *cobra.Command, opts initOptions) (*state.LocalCfg, error) {
// serving enrols into an existing network so both configs must already exist
func loadServeConfigs(opts initOptions) (*state.LocalCfg, *state.CentralCfg, error) {
data, err := os.ReadFile(opts.output)
if err == nil {
cfg, err := state.ParseLocalConfig(data)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", opts.output, err)
}
if err = state.NodeConfigValidator(nil, cfg); err != nil {
return nil, fmt.Errorf("invalid node config: %w", err)
}
return cfg, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read %s: %w", opts.output, err)
}
cfg, err := buildNodeConfig(opts)
if err != nil {
return nil, err
return nil, nil, fmt.Errorf("read %s: %w", opts.output, err)
}
if err = writeNodeConfig(cfg, opts.output, false); err != nil {
return nil, err
node, err := state.ParseLocalConfig(data)
if err != nil {
return nil, nil, fmt.Errorf("parse %s: %w", opts.output, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Created %s\n", opts.output)
return cfg, nil
}

func loadOrInitCentral(opts initOptions, node *state.LocalCfg) (*state.CentralCfg, bool, error) {
path := opts.central
cfg := &state.CentralCfg{}
data, err := os.ReadFile(path)
existed := err == nil
if existed {
if err = yaml.Unmarshal(data, cfg); err != nil {
return nil, false, fmt.Errorf("parse %s: %w", path, err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, false, fmt.Errorf("read %s: %w", path, err)
data, err = os.ReadFile(opts.central)
if err != nil {
return nil, nil, fmt.Errorf("read %s: %w", opts.central, err)
}
if cfg.TryGetNode(node.Id) == nil {
addrs, prefixes, err := entryFlags(opts)
if err != nil {
return nil, false, err
}
cfg.Routers = append(cfg.Routers, state.RouterCfg{NodeCfg: state.NodeCfg{
Id: node.Id,
PubKey: node.Key.Pubkey(),
Addresses: addrs,
Prefixes: staticPrefixes(prefixes),
}})
central := &state.CentralCfg{}
if err = yaml.Unmarshal(data, central); err != nil {
return nil, nil, fmt.Errorf("parse %s: %w", opts.central, err)
}
if err = state.CentralConfigValidator(cfg); err != nil {
return nil, false, fmt.Errorf("invalid central config: %w", err)
if err = state.CentralConfigValidator(central); err != nil {
return nil, nil, fmt.Errorf("invalid central config: %w", err)
}
if err = state.NodeConfigValidator(cfg, node); err != nil {
return nil, false, fmt.Errorf("invalid node config: %w", err)
if err = state.NodeConfigValidator(central, node); err != nil {
return nil, nil, fmt.Errorf("invalid node config: %w", err)
}
return cfg, existed, nil
return node, central, nil
}

func joinConfigPath(central string) string {
Expand Down Expand Up @@ -262,7 +219,7 @@ func checkPendingJoin(path string, central *state.CentralCfg) error {
return nil
}

func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, host string) (*state.CentralCfg, error) {
func enrolNode(central *state.CentralCfg, req joinRequest) (*state.CentralCfg, error) {
err, cfg := central.Clone()
if err != nil {
return nil, err
Expand All @@ -289,14 +246,6 @@ func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest,
Addresses: req.Addresses,
Prefixes: staticPrefixes(req.Prefixes),
}})

// the joining node reached us through host so it is a usable endpoint
idx := slices.IndexFunc(cfg.Routers, func(r state.RouterCfg) bool { return r.Id == self.Id })
if idx != -1 && len(cfg.Routers[idx].Endpoints) == 0 {
if h, _, err := net.SplitHostPort(host); err == nil {
cfg.Routers[idx].Endpoints = []string{net.JoinHostPort(h, strconv.Itoa(int(self.Port)))}
}
}
if err = state.CentralConfigValidator(cfg); err != nil {
return nil, err
}
Expand Down
90 changes: 55 additions & 35 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,31 @@ func freePort(t *testing.T) int {
return port
}

// creates an existing single node network for --serve to enrol into
func setupHub(t *testing.T, dir string, port int) []byte {
cmd := newInitCmd()
cmd.SetOut(io.Discard)
cmd.SetArgs([]string{"--id", "hub", "--port", strconv.Itoa(port), "-o", filepath.Join(dir, "node.yaml")})
require.NoError(t, cmd.Execute())
data, err := os.ReadFile(filepath.Join(dir, "node.yaml"))
require.NoError(t, err)
node, err := state.ParseLocalConfig(data)
require.NoError(t, err)
central, err := yaml.Marshal(state.CentralCfg{Routers: []state.RouterCfg{{
NodeCfg: state.NodeCfg{Id: "hub", PubKey: node.Key.Pubkey(), Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")}},
Endpoints: []string{"127.0.0.1:" + strconv.Itoa(port)},
}}})
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, "central.yaml"), central, 0o600))
return central
}

// starts --serve and returns its token and a channel with its result
func startServe(t *testing.T, dir string, port int, args ...string) (string, chan error) {
func startServe(t *testing.T, dir string) (string, chan error) {
pr, pw := io.Pipe()
serve := newInitCmd()
serve.SetOut(pw)
serve.SetArgs(append([]string{"--serve", "--id", "hub", "--port", strconv.Itoa(port),
"-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")}, args...))
serve.SetArgs([]string{"--serve", "-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")})
done := make(chan error, 1)
go func() {
err := serve.Execute()
Expand Down Expand Up @@ -128,56 +146,58 @@ func TestInitServeConnect(t *testing.T) {
serverCentral := filepath.Join(serverDir, "central.yaml")
joinCentral := filepath.Join(serverDir, "central.join.yaml")

// new network writes central.yaml directly
token, done := startServe(t, serverDir, port, "--address", "10.0.0.1")
// serving needs an existing network
serve := newInitCmd()
serve.SetArgs([]string{"--serve", "-o", filepath.Join(serverDir, "node.yaml"), "-c", serverCentral})
require.ErrorContains(t, serve.Execute(), "node.yaml")
serverData := setupHub(t, serverDir, port)

token, done := startServe(t, serverDir)
require.NotEmpty(t, token)
// rejected joins must not consume the token
require.ErrorContains(t, connect(t.TempDir(), port, "nope", "evil"), "invalid setup token")
require.ErrorContains(t, connect(t.TempDir(), port, token, "dup", "--address", "10.0.0.1"), "already used by hub")
require.NoError(t, connect(leafDir, port, token, "leaf", "--address", "10.0.0.2", "--prefix", "192.168.5.0/24"))
require.NoError(t, <-done)

central, serverData := readCentral(t, serverCentral)
// central.yaml is untouched and the join is written beside it
_, after := readCentral(t, serverCentral)
assert.Equal(t, serverData, after)
joined, joinData := readCentral(t, joinCentral)
_, leafData := readCentral(t, filepath.Join(leafDir, "central.yaml"))
assert.Equal(t, serverData, leafData)
assert.NoFileExists(t, joinCentral)
assert.Equal(t, []string{"127.0.0.1:" + strconv.Itoa(port)}, central.GetRouter("hub").Endpoints)
assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, central.GetRouter("hub").Addresses)
assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.2")}, central.GetRouter("leaf").Addresses)
require.Len(t, central.GetRouter("leaf").Prefixes, 1)
assert.Equal(t, netip.MustParsePrefix("192.168.5.0/24"), central.GetRouter("leaf").Prefixes[0].GetPrefix())
assert.Empty(t, central.Graph)
assert.Equal(t, joinData, leafData)
assert.Equal(t, []string{"127.0.0.1:" + strconv.Itoa(port)}, joined.GetRouter("hub").Endpoints)
assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.2")}, joined.GetRouter("leaf").Addresses)
require.Len(t, joined.GetRouter("leaf").Prefixes, 1)
assert.Equal(t, netip.MustParsePrefix("192.168.5.0/24"), joined.GetRouter("leaf").Prefixes[0].GetPrefix())
assert.Empty(t, joined.Graph)
for _, dir := range []string{serverDir, leafDir} {
data, err := os.ReadFile(filepath.Join(dir, "node.yaml"))
require.NoError(t, err)
node, err := state.ParseLocalConfig(data)
require.NoError(t, err)
require.NoError(t, state.NodeConfigValidator(&central, node))
assert.Equal(t, central.GetNode(node.Id).PubKey, node.Key.Pubkey())
require.NoError(t, state.NodeConfigValidator(&joined, node))
assert.Equal(t, joined.GetNode(node.Id).PubKey, node.Key.Pubkey())
}

// existing network keeps central.yaml and writes the join beside it
token, done = startServe(t, serverDir, port)
require.NoError(t, connect(leaf2Dir, port, token, "leaf2", "--address", "10.0.0.3"))
require.NoError(t, <-done)

_, after := readCentral(t, serverCentral)
assert.Equal(t, serverData, after)
joined, joinData := readCentral(t, joinCentral)
_, leaf2Data := readCentral(t, filepath.Join(leaf2Dir, "central.yaml"))
assert.Equal(t, joinData, leaf2Data)
assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.3")}, joined.GetRouter("leaf2").Addresses)
assert.Empty(t, joined.Graph)

// an undistributed join must not be overwritten
serve := newInitCmd()
serve.SetArgs([]string{"--serve", "--port", strconv.Itoa(freePort(t)),
"-o", filepath.Join(serverDir, "node.yaml"), "-c", serverCentral})
require.ErrorContains(t, serve.Execute(), "undistributed join for leaf2")
serve = newInitCmd()
serve.SetArgs([]string{"--serve", "-o", filepath.Join(serverDir, "node.yaml"), "-c", serverCentral})
require.ErrorContains(t, serve.Execute(), "undistributed join for leaf")

// once distributed a new join is allowed again
require.NoError(t, os.WriteFile(serverCentral, joinData, 0o600))
token, done = startServe(t, serverDir, port)
require.NoError(t, connect(t.TempDir(), port, token, "leaf3"))
token, done = startServe(t, serverDir)
require.NoError(t, connect(leaf2Dir, port, token, "leaf2", "--address", "10.0.0.3"))
require.NoError(t, <-done)
joined, _ = readCentral(t, joinCentral)
assert.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.3")}, joined.GetRouter("leaf2").Addresses)
}

func TestInitAddressRequiresConnect(t *testing.T) {
for _, args := range [][]string{{"--serve"}, {"--id", "x"}} {
cmd := newInitCmd()
cmd.SetArgs(append(args, "--address", "10.0.0.1", "-o", filepath.Join(t.TempDir(), "node.yaml")))
require.ErrorContains(t, cmd.Execute(), "only used with --connect")
}
}
8 changes: 4 additions & 4 deletions docs/guides/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,16 @@ The Linux and macOS versions are well tested, but the Windows TUN interface has

## Quick Setup with a Setup Token

Instead of writing `central.yaml` by hand, you can enrol nodes with a one-time setup token. On an existing node (or the first node of a new network), run:
Once your network is running, you can enrol more nodes with a one-time setup token instead of editing `central.yaml` by hand. On a node that is already part of the network, run:

```bash
nylon init --serve --address 10.0.0.1
nylon init --serve
```

This creates `node.yaml` and `central.yaml` if they are missing, prints a setup token, and waits on TCP port `57175` for one node to join. On the new node, run:
This reads its existing `node.yaml` and `central.yaml`, prints a setup token, and waits on TCP port `57175` for one node to join. On the new node, run:

```bash
nylon init --connect <first-node-ip> --token <setup-token> --address 10.0.0.2
nylon init --connect <serving-node-ip> --token <setup-token> --address 10.0.0.2
```

The new node generates its key, is added to the central configuration with the addresses given by `--address` and any prefixes given by `--prefix` (both repeatable), and receives a copy of the updated configuration. The new node has no connections yet: add it to `graph` before distributing the configuration. The node ID defaults to the hostname; pass `--id` to choose another. The exchange is encrypted with the token, and the token can only be used once.
Expand Down
Loading