From 96c0d5fdaa839ad743614335b11e1452e2661362 Mon Sep 17 00:00:00 2001 From: Eric Li Date: Wed, 30 Sep 2026 19:17:34 -0400 Subject: [PATCH] refactor: require an existing network for init --serve --- cmd/init.go | 7 ++- cmd/init_enrol.go | 105 ++++++++------------------------ cmd/init_test.go | 90 ++++++++++++++++----------- docs/guides/getting-started.mdx | 8 +-- 4 files changed, 91 insertions(+), 119 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index ffc5e85..4ed5b6e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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) @@ -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 diff --git a/cmd/init_enrol.go b/cmd/init_enrol.go index 6151efc..8c43469 100644 --- a/cmd/init_enrol.go +++ b/cmd/init_enrol.go @@ -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 } } @@ -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 @@ -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 { @@ -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 @@ -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 } diff --git a/cmd/init_test.go b/cmd/init_test.go index 4a3d1dd..f52ad22 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -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() @@ -128,8 +146,13 @@ 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") @@ -137,47 +160,44 @@ func TestInitServeConnect(t *testing.T) { 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(¢ral, 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") + } } diff --git a/docs/guides/getting-started.mdx b/docs/guides/getting-started.mdx index 0ee7696..5cd7053 100644 --- a/docs/guides/getting-started.mdx +++ b/docs/guides/getting-started.mdx @@ -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 --token --address 10.0.0.2 +nylon init --connect --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.