From 7ab98a1f3ef3d52f5af593d716ccefefe52f47b8 Mon Sep 17 00:00:00 2001 From: Eric Li Date: Wed, 30 Sep 2026 15:37:01 -0400 Subject: [PATCH 1/3] feat: enrol nodes with a one-time setup token --- cmd/init.go | 100 +++++++---- cmd/init_enrol.go | 310 ++++++++++++++++++++++++++++++++ cmd/init_test.go | 71 ++++++++ docs/guides/getting-started.mdx | 18 ++ 4 files changed, 467 insertions(+), 32 deletions(-) create mode 100644 cmd/init_enrol.go diff --git a/cmd/init.go b/cmd/init.go index c8ab18a8..30bca6dc 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -5,6 +5,7 @@ import ( "fmt" "net/netip" "os" + "strings" "github.com/encodeous/nylon/state" "github.com/goccy/go-yaml" @@ -28,6 +29,10 @@ type initOptions struct { preDown []string postUp []string postDown []string + serve bool + connect string + token string + central string } func newInitCmd() *cobra.Command { @@ -38,39 +43,19 @@ func newInitCmd() *cobra.Command { GroupID: "init", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - cfg, err := buildNodeConfig(opts) - if err != nil { - return err + cmd.SilenceUsage = true + if opts.serve { + return runInitServe(cmd, opts) } - - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("encode node config: %w", err) - } - - flags := os.O_WRONLY | os.O_CREATE - if opts.force { - flags |= os.O_TRUNC - } else { - flags |= os.O_EXCL + if opts.connect != "" { + return runInitConnect(cmd, opts) } - file, err := os.OpenFile(opts.output, flags, 0o600) + cfg, err := buildNodeConfig(opts) if err != nil { - if errors.Is(err, os.ErrExist) { - return fmt.Errorf("%s already exists (use --force to overwrite)", opts.output) - } - return fmt.Errorf("create %s: %w", opts.output, err) - } - if err = file.Chmod(0o600); err != nil { - _ = file.Close() - return fmt.Errorf("secure %s: %w", opts.output, err) - } - if _, err = file.Write(data); err != nil { - _ = file.Close() - return fmt.Errorf("write %s: %w", opts.output, err) + return err } - if err = file.Close(); err != nil { - return fmt.Errorf("close %s: %w", opts.output, err) + if err = writeNodeConfig(cfg, opts.output, opts.force); err != nil { + return err } publicKey, err := cfg.Key.Pubkey().MarshalText() @@ -83,8 +68,8 @@ func newInitCmd() *cobra.Command { } flags := cmd.Flags() - flags.StringVar(&opts.id, "id", "", "Unique node ID (required)") - flags.Uint16Var(&opts.port, "port", 57175, "UDP port Nylon listens on") + flags.StringVar(&opts.id, "id", "", "Unique node ID (defaults to hostname)") + flags.Uint16Var(&opts.port, "port", state.DefaultPort, "UDP port Nylon listens on") flags.StringVar(&opts.key, "key", "", "Existing private key (a new key is generated if omitted)") flags.StringVarP(&opts.output, "output", "o", DefaultNodeConfigPath, "Node config output path") flags.BoolVar(&opts.force, "force", false, "Overwrite the output file if it exists") @@ -99,9 +84,51 @@ func newInitCmd() *cobra.Command { flags.StringSliceVar(&opts.preDown, "pre-down", nil, "Command to run before interface shutdown (repeatable)") flags.StringSliceVar(&opts.postUp, "post-up", nil, "Command to run after interface startup (repeatable)") flags.StringSliceVar(&opts.postDown, "post-down", nil, "Command to run after interface shutdown (repeatable)") + flags.BoolVar(&opts.serve, "serve", false, "Print a setup token and wait for a node to join with --connect") + 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") + cmd.MarkFlagsMutuallyExclusive("serve", "connect") + cmd.MarkFlagsRequiredTogether("connect", "token") return cmd } +func writeNodeConfig(cfg *state.LocalCfg, path string, force bool) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode node config: %w", err) + } + return writeSecretFile(path, data, force) +} + +func writeSecretFile(path string, data []byte, force bool) error { + flags := os.O_WRONLY | os.O_CREATE + if force { + flags |= os.O_TRUNC + } else { + flags |= os.O_EXCL + } + file, err := os.OpenFile(path, flags, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("%s already exists (use --force to overwrite)", path) + } + return fmt.Errorf("create %s: %w", path, err) + } + if err = file.Chmod(0o600); err != nil { + _ = file.Close() + return fmt.Errorf("secure %s: %w", path, err) + } + if _, err = file.Write(data); err != nil { + _ = file.Close() + return fmt.Errorf("write %s: %w", path, err) + } + if err = file.Close(); err != nil { + return fmt.Errorf("close %s: %w", path, err) + } + return nil +} + func buildNodeConfig(opts initOptions) (*state.LocalCfg, error) { privateKey := state.GenerateKey() if opts.key != "" { @@ -110,9 +137,18 @@ func buildNodeConfig(opts initOptions) (*state.LocalCfg, error) { } } + id := opts.id + if id == "" { + host, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("--id is required: %w", err) + } + id = strings.ToLower(host) + } + cfg := &state.LocalCfg{ Key: privateKey, - Id: state.NodeId(opts.id), + Id: state.NodeId(id), Port: opts.port, UseSystemRouting: opts.useSystemRouting, NoNetConfigure: opts.noNetConfigure, diff --git a/cmd/init_enrol.go b/cmd/init_enrol.go new file mode 100644 index 00000000..419bb236 --- /dev/null +++ b/cmd/init_enrol.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" +) + +const ( + joinPath = "/nylon/join" + maxJoinBodySize = 1 << 20 +) + +type joinRequest struct { + Id state.NodeId + PubKey state.NyPublicKey +} + +// token doubles as the shared secret sealing both directions of the exchange +func tokenKey(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func runInitServe(cmd *cobra.Command, opts initOptions) error { + out := cmd.OutOrStdout() + node, err := loadOrInitNode(cmd, opts) + if err != nil { + return err + } + central, err := loadOrInitCentral(opts.central, node) + if err != nil { + return err + } + + raw := make([]byte, 16) + if _, err = rand.Read(raw); err != nil { + return fmt.Errorf("generate token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + key := tokenKey(token) + + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", node.Port)) + if err != nil { + return fmt.Errorf("listen: %w", err) + } + + var mu sync.Mutex + used := false + joined := make(chan joinRequest, 1) + srv := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != joinPath { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxJoinBodySize)) + if err != nil { + http.Error(w, "read request", http.StatusBadRequest) + return + } + plain, err := state.OpenBundle(body, key) + if err != nil { + http.Error(w, "invalid setup token", http.StatusForbidden) + return + } + var req joinRequest + if err = yaml.Unmarshal(plain, &req); err != nil { + http.Error(w, "invalid join request", http.StatusBadRequest) + return + } + + mu.Lock() + defer mu.Unlock() + if used { + http.Error(w, "setup token already used", http.StatusForbidden) + return + } + updated, err := enrolNode(central, node, req, r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + data, err := yaml.Marshal(updated) + if err == nil { + err = os.WriteFile(opts.central, data, 0o600) + } + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: write %s: %v\n", opts.central, err) + http.Error(w, "failed to update central config", http.StatusInternalServerError) + return + } + sealed, err := state.SealBundle(data, key) + if err != nil { + http.Error(w, "failed to seal central config", http.StatusInternalServerError) + return + } + _, _ = w.Write(sealed) + used = true + central = updated + joined <- req + }), + } + go func() { _ = srv.Serve(ln) }() + + fmt.Fprintf(out, "Setup token: %s\n\nOn the new node, run:\n\n nylon init --connect --token %s\n\nWaiting for a node to join on tcp port %d...\n", token, token, node.Port) + req := <-joined + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + + fmt.Fprintf(out, "\nNode %s joined with address %v, updated %s\n", req.Id, central.GetNode(req.Id).Addresses, opts.central) + fmt.Fprintln(out, "Start nylon, or run `nylon reload` if it is already running with this config") + if central.Dist != nil { + // central.yaml is the unencrypted copy of the bundle so it can be resealed directly + fmt.Fprintf(out, "\nThis network uses config distribution. Seal the updated config and publish it to %s,\notherwise the next bundle will overwrite this change:\n\n nylon seal -c %s -k %s -o %s\n", + strings.Join(central.Dist.Repos, ", "), opts.central, DefaultKeyPath, DefaultBundlePath) + } else if len(central.GetNodes()) > 2 { + fmt.Fprintf(out, "Copy %s to the other nodes (or seal and publish it) so they learn about %s\n", opts.central, req.Id) + } + 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) { + 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 + } + if err = writeNodeConfig(cfg, opts.output, false); err != nil { + return nil, err + } + fmt.Fprintf(cmd.OutOrStdout(), "Created %s\n", opts.output) + return cfg, nil +} + +func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, error) { + cfg := &state.CentralCfg{} + data, err := os.ReadFile(path) + if err == nil { + if err = yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if cfg.TryGetNode(node.Id) == nil { + cfg.Routers = append(cfg.Routers, state.RouterCfg{NodeCfg: state.NodeCfg{ + Id: node.Id, + PubKey: node.Key.Pubkey(), + Addresses: []netip.Addr{nextAddress(cfg)}, + }}) + } + if err = state.CentralConfigValidator(cfg); err != nil { + return nil, fmt.Errorf("invalid central config: %w", err) + } + if err = state.NodeConfigValidator(cfg, node); err != nil { + return nil, fmt.Errorf("invalid node config: %w", err) + } + return cfg, nil +} + +func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, host string) (*state.CentralCfg, error) { + err, cfg := central.Clone() + if err != nil { + return nil, err + } + if err = state.NameValidator(string(req.Id)); err != nil { + return nil, err + } + if cfg.IsNode(req.Id) { + return nil, fmt.Errorf("node %s already exists, pick another --id", req.Id) + } + if cfg.FindNodeBy(req.PubKey) != nil { + return nil, fmt.Errorf("public key is already in use") + } + cfg.Routers = append(cfg.Routers, state.RouterCfg{NodeCfg: state.NodeCfg{ + Id: req.Id, + PubKey: req.PubKey, + Addresses: []netip.Addr{nextAddress(cfg)}, + }}) + cfg.Graph = append(cfg.Graph, fmt.Sprintf("%s, %s", self.Id, req.Id)) + + // 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 + } + return cfg, nil +} + +// next ipv4 after the highest one in use, starting at 10.0.0.1 +func nextAddress(cfg *state.CentralCfg) netip.Addr { + var highest netip.Addr + for _, n := range cfg.GetNodes() { + for _, a := range n.Addresses { + if a.Is4() && (!highest.IsValid() || highest.Less(a)) { + highest = a + } + } + } + if !highest.IsValid() { + return netip.MustParseAddr("10.0.0.1") + } + return highest.Next() +} + +func runInitConnect(cmd *cobra.Command, opts initOptions) error { + // fail before enrolling so the server does not register a node we cannot save + if !opts.force { + for _, p := range []string{opts.output, opts.central} { + if _, err := os.Stat(p); err == nil { + return fmt.Errorf("%s already exists (use --force to overwrite)", p) + } + } + } + node, err := buildNodeConfig(opts) + if err != nil { + return err + } + + key := tokenKey(opts.token) + plain, err := yaml.Marshal(joinRequest{Id: node.Id, PubKey: node.Key.Pubkey()}) + if err != nil { + return err + } + sealed, err := state.SealBundle(plain, key) + if err != nil { + return err + } + host := opts.connect + if _, _, err := net.SplitHostPort(host); err != nil { + host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(state.DefaultPort)) + } + client := &http.Client{Timeout: 30 * time.Second} + res, err := client.Post("http://"+host+joinPath, "application/octet-stream", bytes.NewReader(sealed)) + if err != nil { + return fmt.Errorf("connect to %s: %w", host, err) + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, maxJoinBodySize)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if res.StatusCode != http.StatusOK { + return fmt.Errorf("join rejected: %s", strings.TrimSpace(string(body))) + } + data, err := state.OpenBundle(body, key) + if err != nil { + return fmt.Errorf("response was not sealed with the setup token: %w", err) + } + var central state.CentralCfg + if err = yaml.Unmarshal(data, ¢ral); err != nil { + return fmt.Errorf("parse central config: %w", err) + } + if err = state.CentralConfigValidator(¢ral); err != nil { + return fmt.Errorf("invalid central config: %w", err) + } + if err = state.NodeConfigValidator(¢ral, node); err != nil { + return fmt.Errorf("invalid node config: %w", err) + } + + if err = writeNodeConfig(node, opts.output, opts.force); err != nil { + return err + } + if err = writeSecretFile(opts.central, data, opts.force); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Joined as %s with address %v\nCreated %s and %s\nStart nylon with: sudo nylon run -c %s -n %s\n", + node.Id, central.GetNode(node.Id).Addresses, opts.output, opts.central, opts.central, opts.output) + return nil +} diff --git a/cmd/init_test.go b/cmd/init_test.go index 91120457..4f0f4fdb 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,9 +1,14 @@ package cmd import ( + "bufio" "bytes" + "io" + "net" + "net/netip" "os" "path/filepath" + "strconv" "strings" "testing" @@ -66,3 +71,69 @@ func TestBuildNodeConfigRejectsInvalidValues(t *testing.T) { _, err = buildNodeConfig(initOptions{id: "router-1", port: 57175, excludeIPs: []string{"not-a-prefix"}}) require.ErrorContains(t, err, "invalid --exclude-ip") } + +func TestInitServeConnect(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + require.NoError(t, ln.Close()) + + serverDir, clientDir := t.TempDir(), t.TempDir() + pr, pw := io.Pipe() + serve := newInitCmd() + serve.SetOut(pw) + serve.SetArgs([]string{"--serve", "--id", "hub", "--port", strconv.Itoa(port), + "-o", filepath.Join(serverDir, "node.yaml"), "-c", filepath.Join(serverDir, "central.yaml")}) + done := make(chan error, 1) + go func() { + done <- serve.Execute() + _ = pw.Close() + }() + + var token string + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + if v, ok := strings.CutPrefix(scanner.Text(), "Setup token: "); ok { + token = v + break + } + } + require.NotEmpty(t, token) + go func() { _, _ = io.Copy(io.Discard, pr) }() + + // wrong token must be rejected without consuming the real one + bad := newInitCmd() + bad.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", "nope", "--id", "evil", + "-o", filepath.Join(clientDir, "evil.yaml"), "-c", filepath.Join(clientDir, "evil-central.yaml")}) + require.ErrorContains(t, bad.Execute(), "invalid setup token") + + connect := newInitCmd() + connect.SetOut(io.Discard) + connect.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", token, "--id", "leaf", + "-o", filepath.Join(clientDir, "node.yaml"), "-c", filepath.Join(clientDir, "central.yaml")}) + require.NoError(t, connect.Execute()) + require.NoError(t, <-done) + + serverCentral, err := os.ReadFile(filepath.Join(serverDir, "central.yaml")) + require.NoError(t, err) + clientCentral, err := os.ReadFile(filepath.Join(clientDir, "central.yaml")) + require.NoError(t, err) + assert.Equal(t, serverCentral, clientCentral) + + var central state.CentralCfg + require.NoError(t, yaml.Unmarshal(clientCentral, ¢ral)) + require.NoError(t, state.CentralConfigValidator(¢ral)) + 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) + assert.Equal(t, []state.NodeId{"leaf"}, central.GetPeers("hub")) + + for _, dir := range []string{serverDir, clientDir} { + 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()) + } +} diff --git a/docs/guides/getting-started.mdx b/docs/guides/getting-started.mdx index 3edb4314..50aaa907 100644 --- a/docs/guides/getting-started.mdx +++ b/docs/guides/getting-started.mdx @@ -115,6 +115,24 @@ 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: + +```bash +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: + +```bash +nylon init --connect --token +``` + +The new node generates its key, is added to `central.yaml` with the next free address and connected to the serving node, and receives a copy of the updated `central.yaml`. 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. + +Afterwards, run `nylon reload` on the serving node if nylon is already running, and distribute the updated `central.yaml` to any other nodes. If the network uses [Config Distribution](/guides/config-distribution), the serving node's `central.yaml` is the unencrypted copy of the bundle: reseal it with `nylon seal` and publish it, otherwise the next bundle will overwrite the change. + ## Next Steps - Learn how to connect [Passive Nodes](/guides/wg-clients) to support edge platforms like iOS. From e0576ab37933aadb12c42d8214791dbb10bb0bdb Mon Sep 17 00:00:00 2001 From: Eric Li Date: Wed, 30 Sep 2026 16:03:12 -0400 Subject: [PATCH 2/3] feat: keep serving node config unchanged until join is distributed --- cmd/init_enrol.go | 82 +++++++++++++++++++------ cmd/init_test.go | 102 +++++++++++++++++++++++--------- docs/guides/getting-started.mdx | 4 +- 3 files changed, 141 insertions(+), 47 deletions(-) diff --git a/cmd/init_enrol.go b/cmd/init_enrol.go index 419bb236..f5120a9d 100644 --- a/cmd/init_enrol.go +++ b/cmd/init_enrol.go @@ -13,6 +13,7 @@ import ( "net/http" "net/netip" "os" + "path/filepath" "slices" "strconv" "strings" @@ -46,10 +47,20 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error { if err != nil { return err } - central, err := loadOrInitCentral(opts.central, node) + central, existed, err := loadOrInitCentral(opts.central, 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 + } + } + } raw := make([]byte, 16) if _, err = rand.Read(raw); err != nil { @@ -102,10 +113,10 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error { } data, err := yaml.Marshal(updated) if err == nil { - err = os.WriteFile(opts.central, data, 0o600) + err = os.WriteFile(outPath, data, 0o600) } if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Error: write %s: %v\n", opts.central, err) + fmt.Fprintf(cmd.ErrOrStderr(), "Error: write %s: %v\n", outPath, err) http.Error(w, "failed to update central config", http.StatusInternalServerError) return } @@ -128,14 +139,16 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error { defer cancel() _ = srv.Shutdown(ctx) - fmt.Fprintf(out, "\nNode %s joined with address %v, updated %s\n", req.Id, central.GetNode(req.Id).Addresses, opts.central) - fmt.Fprintln(out, "Start nylon, or run `nylon reload` if it is already running with this config") - if central.Dist != nil { - // central.yaml is the unencrypted copy of the bundle so it can be resealed directly - fmt.Fprintf(out, "\nThis network uses config distribution. Seal the updated config and publish it to %s,\notherwise the next bundle will overwrite this change:\n\n nylon seal -c %s -k %s -o %s\n", - strings.Join(central.Dist.Repos, ", "), opts.central, DefaultKeyPath, DefaultBundlePath) - } else if len(central.GetNodes()) > 2 { - fmt.Fprintf(out, "Copy %s to the other nodes (or seal and publish it) so they learn about %s\n", opts.central, req.Id) + addrs := central.GetNode(req.Id).Addresses + switch { + case !existed: + fmt.Fprintf(out, "\nNode %s joined with address %v, created %s\nStart nylon to connect to it\n", req.Id, addrs, outPath) + case central.Dist != nil: + fmt.Fprintf(out, "\nNode %s joined with address %v, wrote the updated config to %s\nSeal it and publish it to %s, %s can connect once the bundle is distributed:\n\n nylon seal -c %s -k %s -o %s\n", + req.Id, addrs, outPath, strings.Join(central.Dist.Repos, ", "), req.Id, outPath, DefaultKeyPath, DefaultBundlePath) + default: + fmt.Fprintf(out, "\nNode %s joined with address %v, wrote the updated config to %s\nCopy it to every node (including this one) as %s and run `nylon reload`, %s can connect once it is distributed\n", + req.Id, addrs, outPath, filepath.Base(opts.central), req.Id) } return nil } @@ -167,15 +180,16 @@ func loadOrInitNode(cmd *cobra.Command, opts initOptions) (*state.LocalCfg, erro return cfg, nil } -func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, error) { +func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, bool, error) { cfg := &state.CentralCfg{} data, err := os.ReadFile(path) - if err == nil { + existed := err == nil + if existed { if err = yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) + return nil, false, fmt.Errorf("parse %s: %w", path, err) } } else if !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("read %s: %w", path, err) + return nil, false, fmt.Errorf("read %s: %w", path, err) } if cfg.TryGetNode(node.Id) == nil { cfg.Routers = append(cfg.Routers, state.RouterCfg{NodeCfg: state.NodeCfg{ @@ -185,12 +199,38 @@ func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, er }}) } if err = state.CentralConfigValidator(cfg); err != nil { - return nil, fmt.Errorf("invalid central config: %w", err) + return nil, false, fmt.Errorf("invalid central config: %w", err) } if err = state.NodeConfigValidator(cfg, node); err != nil { - return nil, fmt.Errorf("invalid node config: %w", err) + return nil, false, fmt.Errorf("invalid node config: %w", err) } - return cfg, nil + return cfg, existed, nil +} + +func joinConfigPath(central string) string { + ext := filepath.Ext(central) + return strings.TrimSuffix(central, ext) + ".join" + ext +} + +// a previous join is distributed once all its nodes reach central config +func checkPendingJoin(path string, central *state.CentralCfg) error { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + var pending state.CentralCfg + if err = yaml.Unmarshal(data, &pending); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + for _, n := range pending.GetNodes() { + if !central.IsNode(n.Id) { + return fmt.Errorf("%s has an undistributed join for %s, distribute it first (or use --force to discard it)", path, n.Id) + } + } + return nil } func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, host string) (*state.CentralCfg, error) { @@ -224,6 +264,12 @@ func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, if err = state.CentralConfigValidator(cfg); err != nil { return nil, err } + // the joiner has no endpoint of its own so it must be able to dial a peer + if !slices.ContainsFunc(cfg.GetPeers(req.Id), func(p state.NodeId) bool { + return cfg.IsRouter(p) && len(cfg.GetRouter(p).Endpoints) != 0 + }) { + return nil, fmt.Errorf("%s would have no peer with an endpoint to connect to", req.Id) + } return cfg, nil } diff --git a/cmd/init_test.go b/cmd/init_test.go index 4f0f4fdb..e7ba151d 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -72,24 +72,27 @@ func TestBuildNodeConfigRejectsInvalidValues(t *testing.T) { require.ErrorContains(t, err, "invalid --exclude-ip") } -func TestInitServeConnect(t *testing.T) { +func freePort(t *testing.T) int { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) port := ln.Addr().(*net.TCPAddr).Port require.NoError(t, ln.Close()) + return port +} - serverDir, clientDir := t.TempDir(), t.TempDir() +// 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) { pr, pw := io.Pipe() serve := newInitCmd() serve.SetOut(pw) - serve.SetArgs([]string{"--serve", "--id", "hub", "--port", strconv.Itoa(port), - "-o", filepath.Join(serverDir, "node.yaml"), "-c", filepath.Join(serverDir, "central.yaml")}) + serve.SetArgs(append([]string{"--serve", "--id", "hub", "--port", strconv.Itoa(port), + "-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")}, args...)) done := make(chan error, 1) go func() { - done <- serve.Execute() + err := serve.Execute() _ = pw.Close() + done <- err }() - var token string scanner := bufio.NewScanner(pr) for scanner.Scan() { @@ -98,37 +101,50 @@ func TestInitServeConnect(t *testing.T) { break } } - require.NotEmpty(t, token) go func() { _, _ = io.Copy(io.Discard, pr) }() + return token, done +} - // wrong token must be rejected without consuming the real one - bad := newInitCmd() - bad.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", "nope", "--id", "evil", - "-o", filepath.Join(clientDir, "evil.yaml"), "-c", filepath.Join(clientDir, "evil-central.yaml")}) - require.ErrorContains(t, bad.Execute(), "invalid setup token") - - connect := newInitCmd() - connect.SetOut(io.Discard) - connect.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", token, "--id", "leaf", - "-o", filepath.Join(clientDir, "node.yaml"), "-c", filepath.Join(clientDir, "central.yaml")}) - require.NoError(t, connect.Execute()) - require.NoError(t, <-done) +func connect(dir string, port int, token, id string) error { + cmd := newInitCmd() + cmd.SetOut(io.Discard) + cmd.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", token, "--id", id, + "-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")}) + return cmd.Execute() +} - serverCentral, err := os.ReadFile(filepath.Join(serverDir, "central.yaml")) +func readCentral(t *testing.T, path string) (state.CentralCfg, []byte) { + data, err := os.ReadFile(path) require.NoError(t, err) - clientCentral, err := os.ReadFile(filepath.Join(clientDir, "central.yaml")) - require.NoError(t, err) - assert.Equal(t, serverCentral, clientCentral) - var central state.CentralCfg - require.NoError(t, yaml.Unmarshal(clientCentral, ¢ral)) + require.NoError(t, yaml.Unmarshal(data, ¢ral)) require.NoError(t, state.CentralConfigValidator(¢ral)) + return central, data +} + +func TestInitServeConnect(t *testing.T) { + port := freePort(t) + serverDir, leafDir, leaf2Dir := t.TempDir(), t.TempDir(), t.TempDir() + 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) + require.NotEmpty(t, token) + // wrong token must be rejected without consuming the real one + require.ErrorContains(t, connect(t.TempDir(), port, "nope", "evil"), "invalid setup token") + require.NoError(t, connect(leafDir, port, token, "leaf")) + require.NoError(t, <-done) + + central, serverData := readCentral(t, serverCentral) + _, 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) assert.Equal(t, []state.NodeId{"leaf"}, central.GetPeers("hub")) - - for _, dir := range []string{serverDir, clientDir} { + for _, dir := range []string{serverDir, leafDir} { data, err := os.ReadFile(filepath.Join(dir, "node.yaml")) require.NoError(t, err) node, err := state.ParseLocalConfig(data) @@ -136,4 +152,36 @@ func TestInitServeConnect(t *testing.T) { require.NoError(t, state.NodeConfigValidator(¢ral, node)) assert.Equal(t, central.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")) + 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.ElementsMatch(t, []state.NodeId{"leaf", "leaf2"}, joined.GetPeers("hub")) + + // 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") + + // 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")) + require.NoError(t, <-done) +} + +func TestEnrolNodeRequiresReachablePeer(t *testing.T) { + self := &state.LocalCfg{Id: "hub", Key: state.GenerateKey(), Port: state.DefaultPort} + central := &state.CentralCfg{Clients: []state.ClientCfg{{NodeCfg: state.NodeCfg{Id: "hub", PubKey: self.Key.Pubkey()}}}} + _, err := enrolNode(central, self, joinRequest{Id: "leaf", PubKey: state.GenerateKey().Pubkey()}, "10.1.1.1:57175") + assert.ErrorContains(t, err, "no peer with an endpoint") } diff --git a/docs/guides/getting-started.mdx b/docs/guides/getting-started.mdx index 50aaa907..f0af11aa 100644 --- a/docs/guides/getting-started.mdx +++ b/docs/guides/getting-started.mdx @@ -129,9 +129,9 @@ This creates `node.yaml` and `central.yaml` if they are missing, prints a setup nylon init --connect --token ``` -The new node generates its key, is added to `central.yaml` with the next free address and connected to the serving node, and receives a copy of the updated `central.yaml`. 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. +The new node generates its key, is added to the central configuration with the next free address and connected to the serving node, and receives a copy of the updated 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. -Afterwards, run `nylon reload` on the serving node if nylon is already running, and distribute the updated `central.yaml` to any other nodes. If the network uses [Config Distribution](/guides/config-distribution), the serving node's `central.yaml` is the unencrypted copy of the bundle: reseal it with `nylon seal` and publish it, otherwise the next bundle will overwrite the change. +The serving node keeps its own `central.yaml` unchanged, so it stays in sync with the rest of the network, and writes the updated configuration to `central.join.yaml` instead. The new node can connect once that configuration is distributed: copy it to every node as `central.yaml` and run `nylon reload`, or, if the network uses [Config Distribution](/guides/config-distribution), seal it with `nylon seal` and publish the bundle. The serving node prints the exact steps. ## Next Steps From 72b49cebbd67b17633479c0dd55cff96ba40be8a Mon Sep 17 00:00:00 2001 From: Eric Li Date: Wed, 30 Sep 2026 17:17:18 -0400 Subject: [PATCH 3/3] feat: take node addresses and prefixes from flags and leave graph untouched --- cmd/init.go | 16 ++++++ cmd/init_enrol.go | 90 ++++++++++++++++++++------------- cmd/init_test.go | 28 +++++----- docs/guides/getting-started.mdx | 6 +-- 4 files changed, 85 insertions(+), 55 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 30bca6dc..ffc5e852 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -33,6 +33,8 @@ type initOptions struct { connect string token string central string + addresses []string + prefixes []string } func newInitCmd() *cobra.Command { @@ -88,6 +90,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)") cmd.MarkFlagsMutuallyExclusive("serve", "connect") cmd.MarkFlagsRequiredTogether("connect", "token") return cmd @@ -175,6 +179,18 @@ func buildNodeConfig(opts initOptions) (*state.LocalCfg, error) { return cfg, nil } +func parseAddrs(values []string) ([]netip.Addr, error) { + addrs := make([]netip.Addr, 0, len(values)) + for _, value := range values { + addr, err := netip.ParseAddr(value) + if err != nil { + return nil, fmt.Errorf("%q: %w", value, err) + } + addrs = append(addrs, addr) + } + return addrs, nil +} + func parsePrefixes(values []string) ([]netip.Prefix, error) { prefixes := make([]netip.Prefix, 0, len(values)) for _, value := range values { diff --git a/cmd/init_enrol.go b/cmd/init_enrol.go index f5120a9d..6151efc5 100644 --- a/cmd/init_enrol.go +++ b/cmd/init_enrol.go @@ -31,8 +31,31 @@ const ( ) type joinRequest struct { - Id state.NodeId - PubKey state.NyPublicKey + Id state.NodeId + PubKey state.NyPublicKey + Addresses []netip.Addr `yaml:",omitempty"` + Prefixes []netip.Prefix `yaml:",omitempty"` +} + +// address and prefix flags describe this node's entry in central config +func entryFlags(opts initOptions) ([]netip.Addr, []netip.Prefix, error) { + addrs, err := parseAddrs(opts.addresses) + if err != nil { + return nil, nil, fmt.Errorf("invalid --address: %w", err) + } + prefixes, err := parsePrefixes(opts.prefixes) + if err != nil { + return nil, nil, fmt.Errorf("invalid --prefix: %w", err) + } + return addrs, prefixes, nil +} + +func staticPrefixes(prefixes []netip.Prefix) []state.PrefixHealthWrapper { + res := make([]state.PrefixHealthWrapper, 0, len(prefixes)) + for _, p := range prefixes { + res = append(res, state.PrefixHealthWrapper{PrefixHealth: &state.StaticPrefixHealth{Prefix: p}}) + } + return res } // token doubles as the shared secret sealing both directions of the exchange @@ -47,7 +70,7 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error { if err != nil { return err } - central, existed, err := loadOrInitCentral(opts.central, node) + central, existed, err := loadOrInitCentral(opts, node) if err != nil { return err } @@ -139,16 +162,16 @@ func runInitServe(cmd *cobra.Command, opts initOptions) error { defer cancel() _ = srv.Shutdown(ctx) - addrs := central.GetNode(req.Id).Addresses + 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.Fprintf(out, "\nNode %s joined with address %v, created %s\nStart nylon to connect to it\n", req.Id, addrs, outPath) + fmt.Fprintln(out, "Start nylon") case central.Dist != nil: - fmt.Fprintf(out, "\nNode %s joined with address %v, wrote the updated config to %s\nSeal it and publish it to %s, %s can connect once the bundle is distributed:\n\n nylon seal -c %s -k %s -o %s\n", - req.Id, addrs, outPath, strings.Join(central.Dist.Repos, ", "), req.Id, outPath, DefaultKeyPath, DefaultBundlePath) + 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: - fmt.Fprintf(out, "\nNode %s joined with address %v, wrote the updated config to %s\nCopy it to every node (including this one) as %s and run `nylon reload`, %s can connect once it is distributed\n", - req.Id, addrs, outPath, filepath.Base(opts.central), req.Id) + fmt.Fprintf(out, "Copy it to every node (including this one) as %s and run `nylon reload`\n", filepath.Base(opts.central)) } return nil } @@ -180,7 +203,8 @@ func loadOrInitNode(cmd *cobra.Command, opts initOptions) (*state.LocalCfg, erro return cfg, nil } -func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, bool, error) { +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 @@ -192,10 +216,15 @@ func loadOrInitCentral(path string, node *state.LocalCfg) (*state.CentralCfg, bo return nil, false, fmt.Errorf("read %s: %w", path, 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: []netip.Addr{nextAddress(cfg)}, + Addresses: addrs, + Prefixes: staticPrefixes(prefixes), }}) } if err = state.CentralConfigValidator(cfg); err != nil { @@ -247,12 +276,19 @@ func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, if cfg.FindNodeBy(req.PubKey) != nil { return nil, fmt.Errorf("public key is already in use") } + for _, n := range cfg.GetNodes() { + for _, a := range n.Addresses { + if slices.Contains(req.Addresses, a) { + return nil, fmt.Errorf("address %s is already used by %s", a, n.Id) + } + } + } cfg.Routers = append(cfg.Routers, state.RouterCfg{NodeCfg: state.NodeCfg{ Id: req.Id, PubKey: req.PubKey, - Addresses: []netip.Addr{nextAddress(cfg)}, + Addresses: req.Addresses, + Prefixes: staticPrefixes(req.Prefixes), }}) - cfg.Graph = append(cfg.Graph, fmt.Sprintf("%s, %s", self.Id, req.Id)) // 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 }) @@ -264,31 +300,9 @@ func enrolNode(central *state.CentralCfg, self *state.LocalCfg, req joinRequest, if err = state.CentralConfigValidator(cfg); err != nil { return nil, err } - // the joiner has no endpoint of its own so it must be able to dial a peer - if !slices.ContainsFunc(cfg.GetPeers(req.Id), func(p state.NodeId) bool { - return cfg.IsRouter(p) && len(cfg.GetRouter(p).Endpoints) != 0 - }) { - return nil, fmt.Errorf("%s would have no peer with an endpoint to connect to", req.Id) - } return cfg, nil } -// next ipv4 after the highest one in use, starting at 10.0.0.1 -func nextAddress(cfg *state.CentralCfg) netip.Addr { - var highest netip.Addr - for _, n := range cfg.GetNodes() { - for _, a := range n.Addresses { - if a.Is4() && (!highest.IsValid() || highest.Less(a)) { - highest = a - } - } - } - if !highest.IsValid() { - return netip.MustParseAddr("10.0.0.1") - } - return highest.Next() -} - func runInitConnect(cmd *cobra.Command, opts initOptions) error { // fail before enrolling so the server does not register a node we cannot save if !opts.force { @@ -302,9 +316,13 @@ func runInitConnect(cmd *cobra.Command, opts initOptions) error { if err != nil { return err } + addrs, prefixes, err := entryFlags(opts) + if err != nil { + return err + } key := tokenKey(opts.token) - plain, err := yaml.Marshal(joinRequest{Id: node.Id, PubKey: node.Key.Pubkey()}) + plain, err := yaml.Marshal(joinRequest{Id: node.Id, PubKey: node.Key.Pubkey(), Addresses: addrs, Prefixes: prefixes}) if err != nil { return err } diff --git a/cmd/init_test.go b/cmd/init_test.go index e7ba151d..4a3d1dd2 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -105,11 +105,11 @@ func startServe(t *testing.T, dir string, port int, args ...string) (string, cha return token, done } -func connect(dir string, port int, token, id string) error { +func connect(dir string, port int, token, id string, args ...string) error { cmd := newInitCmd() cmd.SetOut(io.Discard) - cmd.SetArgs([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", token, "--id", id, - "-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")}) + cmd.SetArgs(append([]string{"--connect", "127.0.0.1:" + strconv.Itoa(port), "--token", token, "--id", id, + "-o", filepath.Join(dir, "node.yaml"), "-c", filepath.Join(dir, "central.yaml")}, args...)) return cmd.Execute() } @@ -129,11 +129,12 @@ func TestInitServeConnect(t *testing.T) { joinCentral := filepath.Join(serverDir, "central.join.yaml") // new network writes central.yaml directly - token, done := startServe(t, serverDir, port) + token, done := startServe(t, serverDir, port, "--address", "10.0.0.1") require.NotEmpty(t, token) - // wrong token must be rejected without consuming the real one + // rejected joins must not consume the token require.ErrorContains(t, connect(t.TempDir(), port, "nope", "evil"), "invalid setup token") - require.NoError(t, connect(leafDir, port, token, "leaf")) + 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) @@ -143,7 +144,9 @@ func TestInitServeConnect(t *testing.T) { 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) - assert.Equal(t, []state.NodeId{"leaf"}, central.GetPeers("hub")) + 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) for _, dir := range []string{serverDir, leafDir} { data, err := os.ReadFile(filepath.Join(dir, "node.yaml")) require.NoError(t, err) @@ -155,7 +158,7 @@ func TestInitServeConnect(t *testing.T) { // 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")) + require.NoError(t, connect(leaf2Dir, port, token, "leaf2", "--address", "10.0.0.3")) require.NoError(t, <-done) _, after := readCentral(t, serverCentral) @@ -164,7 +167,7 @@ func TestInitServeConnect(t *testing.T) { _, 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.ElementsMatch(t, []state.NodeId{"leaf", "leaf2"}, joined.GetPeers("hub")) + assert.Empty(t, joined.Graph) // an undistributed join must not be overwritten serve := newInitCmd() @@ -178,10 +181,3 @@ func TestInitServeConnect(t *testing.T) { require.NoError(t, connect(t.TempDir(), port, token, "leaf3")) require.NoError(t, <-done) } - -func TestEnrolNodeRequiresReachablePeer(t *testing.T) { - self := &state.LocalCfg{Id: "hub", Key: state.GenerateKey(), Port: state.DefaultPort} - central := &state.CentralCfg{Clients: []state.ClientCfg{{NodeCfg: state.NodeCfg{Id: "hub", PubKey: self.Key.Pubkey()}}}} - _, err := enrolNode(central, self, joinRequest{Id: "leaf", PubKey: state.GenerateKey().Pubkey()}, "10.1.1.1:57175") - assert.ErrorContains(t, err, "no peer with an endpoint") -} diff --git a/docs/guides/getting-started.mdx b/docs/guides/getting-started.mdx index f0af11aa..0ee7696a 100644 --- a/docs/guides/getting-started.mdx +++ b/docs/guides/getting-started.mdx @@ -120,16 +120,16 @@ The Linux and macOS versions are well tested, but the Windows TUN interface has 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: ```bash -nylon init --serve +nylon init --serve --address 10.0.0.1 ``` 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: ```bash -nylon init --connect --token +nylon init --connect --token --address 10.0.0.2 ``` -The new node generates its key, is added to the central configuration with the next free address and connected to the serving node, and receives a copy of the updated 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. +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. The serving node keeps its own `central.yaml` unchanged, so it stays in sync with the rest of the network, and writes the updated configuration to `central.join.yaml` instead. The new node can connect once that configuration is distributed: copy it to every node as `central.yaml` and run `nylon reload`, or, if the network uses [Config Distribution](/guides/config-distribution), seal it with `nylon seal` and publish the bundle. The serving node prints the exact steps.