diff --git a/cmd/init.go b/cmd/init.go index c8ab18a8..ffc5e852 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,12 @@ type initOptions struct { preDown []string postUp []string postDown []string + serve bool + connect string + token string + central string + addresses []string + prefixes []string } func newInitCmd() *cobra.Command { @@ -38,39 +45,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) + if opts.connect != "" { + return runInitConnect(cmd, opts) } - - flags := os.O_WRONLY | os.O_CREATE - if opts.force { - flags |= os.O_TRUNC - } else { - flags |= os.O_EXCL - } - 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 +70,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 +86,53 @@ 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") + 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 } +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 +141,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, @@ -139,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 new file mode 100644 index 00000000..6151efc5 --- /dev/null +++ b/cmd/init_enrol.go @@ -0,0 +1,374 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "path/filepath" + "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 + 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 +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, 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 + } + } + } + + 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(outPath, data, 0o600) + } + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Error: write %s: %v\n", outPath, 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, 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: + 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, "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) { + 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(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) + } + 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), + }}) + } + if err = state.CentralConfigValidator(cfg); err != nil { + return nil, false, 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) + } + 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) { + 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") + } + 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: 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 + } + return cfg, nil +} + +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 + } + 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(), Addresses: addrs, Prefixes: prefixes}) + 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..4a3d1dd2 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,113 @@ 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 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 +} + +// 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(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() { + err := serve.Execute() + _ = pw.Close() + done <- err + }() + var token string + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + if v, ok := strings.CutPrefix(scanner.Text(), "Setup token: "); ok { + token = v + break + } + } + go func() { _, _ = io.Copy(io.Discard, pr) }() + return token, done +} + +func connect(dir string, port int, token, id string, args ...string) error { + cmd := newInitCmd() + cmd.SetOut(io.Discard) + 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() +} + +func readCentral(t *testing.T, path string) (state.CentralCfg, []byte) { + data, err := os.ReadFile(path) + require.NoError(t, err) + var central state.CentralCfg + 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, "--address", "10.0.0.1") + 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) + _, 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) + 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()) + } + + // 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") + + // 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) +} diff --git a/docs/guides/getting-started.mdx b/docs/guides/getting-started.mdx index 3edb4314..0ee7696a 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 --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 --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. + +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 - Learn how to connect [Passive Nodes](/guides/wg-clients) to support edge platforms like iOS.