Skip to content
Merged
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
171 changes: 171 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package cmd

import (
"errors"
"fmt"
"net/netip"
"os"

"github.com/encodeous/nylon/state"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
)

type initOptions struct {
id string
port uint16
key string
output string
force bool
useSystemRouting bool
noNetConfigure bool
dnsResolvers []string
interfaceName string
logPath string
distURL string
distKey string
unexcludeIPs []string
excludeIPs []string
preUp []string
preDown []string
postUp []string
postDown []string
}

func newInitCmd() *cobra.Command {
Comment thread
encodeous marked this conversation as resolved.
Resolved
opts := initOptions{}
cmd := &cobra.Command{
Use: "init",
Short: "Generate a node configuration",
GroupID: "init",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := buildNodeConfig(opts)
if err != nil {
return err
}

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
}
file, err := os.OpenFile(opts.output, flags, 0o600)
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)
}
if err = file.Close(); err != nil {
return fmt.Errorf("close %s: %w", opts.output, err)
}

publicKey, err := cfg.Key.Pubkey().MarshalText()
if err != nil {
return fmt.Errorf("encode public key: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Created %s\nPublic key: %s\n", opts.output, publicKey)
return nil
},
}

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.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")
flags.BoolVar(&opts.useSystemRouting, "use-system-routing", false, "Route peer packets through the system")
flags.BoolVar(&opts.noNetConfigure, "no-net-configure", false, "Do not configure system networking")
flags.StringSliceVar(&opts.dnsResolvers, "dns-resolver", nil, "DNS resolver in ip:port form (repeatable)")
flags.StringVar(&opts.interfaceName, "interface-name", "", "Nylon interface name")
flags.StringVar(&opts.logPath, "log-path", "", "Log file path")
flags.StringVar(&opts.distURL, "dist-url", "", "Configuration distribution URL")
flags.StringVar(&opts.distKey, "dist-key", "", "Configuration distribution shared key")
flags.StringSliceVar(&opts.unexcludeIPs, "unexclude-ip", nil, "Centrally excluded IP prefix to include (repeatable)")
flags.StringSliceVar(&opts.excludeIPs, "exclude-ip", nil, "IP prefix to exclude (repeatable)")
flags.StringSliceVar(&opts.preUp, "pre-up", nil, "Command to run before interface startup (repeatable)")
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)")
return cmd
}

func buildNodeConfig(opts initOptions) (*state.LocalCfg, error) {
privateKey := state.GenerateKey()
if opts.key != "" {
if err := privateKey.UnmarshalText([]byte(opts.key)); err != nil {
return nil, fmt.Errorf("invalid private key: %w", err)
}
}

cfg := &state.LocalCfg{
Key: privateKey,
Id: state.NodeId(opts.id),
Port: opts.port,
UseSystemRouting: opts.useSystemRouting,
NoNetConfigure: opts.noNetConfigure,
DnsResolvers: opts.dnsResolvers,
InterfaceName: opts.interfaceName,
LogPath: opts.logPath,
PreUp: opts.preUp,
PreDown: opts.preDown,
PostUp: opts.postUp,
PostDown: opts.postDown,
}

var err error
if cfg.UnexcludeIPs, err = parsePrefixes(opts.unexcludeIPs); err != nil {
return nil, fmt.Errorf("invalid --unexclude-ip: %w", err)
}
if cfg.ExcludeIPs, err = parsePrefixes(opts.excludeIPs); err != nil {
return nil, fmt.Errorf("invalid --exclude-ip: %w", err)
}

if (opts.distURL == "") != (opts.distKey == "") {
return nil, errors.New("--dist-url and --dist-key must be provided together")
}
if opts.distURL != "" {
var key state.NyPublicKey
if err := key.UnmarshalText([]byte(opts.distKey)); err != nil {
return nil, fmt.Errorf("invalid distribution key: %w", err)
}
cfg.Dist = &state.LocalDistributionCfg{Url: opts.distURL, Key: key}
}

if err := state.NodeConfigValidator(nil, cfg); err != nil {
return nil, fmt.Errorf("invalid node config: %w", err)
}
return cfg, nil
}

func parsePrefixes(values []string) ([]netip.Prefix, error) {
prefixes := make([]netip.Prefix, 0, len(values))
for _, value := range values {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return nil, fmt.Errorf("%q: %w", value, err)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}

func init() {
rootCmd.AddCommand(newInitCmd())
}
77 changes: 77 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/encodeous/nylon/state"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInitCommandCreatesValidConfig(t *testing.T) {
output := filepath.Join(t.TempDir(), "node.yaml")
cmd := newInitCmd()
cmd.SetIn(strings.NewReader(""))
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{
"--id", "router-1",
"--output", output,
"--dns-resolver", "1.1.1.1:53",
"--exclude-ip", "192.168.0.0/24",
"--interface-name", "nylon-test",
})

require.NoError(t, cmd.Execute())

data, err := os.ReadFile(output)
require.NoError(t, err)
var cfg state.LocalCfg
require.NoError(t, yaml.Unmarshal(data, &cfg))
require.NoError(t, state.NodeConfigValidator(nil, &cfg))
assert.Equal(t, state.NodeId("router-1"), cfg.Id)
assert.Equal(t, uint16(57175), cfg.Port)
assert.Equal(t, []string{"1.1.1.1:53"}, cfg.DnsResolvers)
assert.Equal(t, "nylon-test", cfg.InterfaceName)
assert.NotContains(t, stdout.String(), "Interactive node configuration")

info, err := os.Stat(output)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}

func TestInitCommandRefusesToOverwrite(t *testing.T) {
output := filepath.Join(t.TempDir(), "node.yaml")
require.NoError(t, os.WriteFile(output, []byte("existing"), 0o600))

cmd := newInitCmd()
cmd.SetArgs([]string{"--id", "router-1", "--output", output})
err := cmd.Execute()
require.ErrorContains(t, err, "already exists")

data, readErr := os.ReadFile(output)
require.NoError(t, readErr)
assert.Equal(t, "existing", string(data))
}

func TestBuildNodeConfigRequiresCompleteDistributionConfig(t *testing.T) {
_, err := buildNodeConfig(initOptions{
id: "router-1",
port: 57175,
distURL: "https://example.com/central.nybundle",
})
require.ErrorContains(t, err, "--dist-url and --dist-key")
}

func TestBuildNodeConfigRejectsInvalidValues(t *testing.T) {
_, err := buildNodeConfig(initOptions{id: "INVALID ID", port: 57175})
require.ErrorContains(t, err, "invalid node config")

_, err = buildNodeConfig(initOptions{id: "router-1", port: 57175, excludeIPs: []string{"not-a-prefix"}})
require.ErrorContains(t, err, "invalid --exclude-ip")
}
34 changes: 15 additions & 19 deletions docs/guides/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,38 +25,34 @@ The Linux and macOS versions are well tested, but the Windows TUN interface has

<Steps>

1. ### Generate Keypairs
1. ### Create Node Configuration

On each node, generate a WireGuard keypair:
On each node, generate `node.yaml` and a WireGuard keypair:

```bash
nylon key
nylon init
```

This will output two keys (example):

```
kPoLiC4+Nh9AoQGiBmJTh+8BUqCMsa6Zdr4M0Xz5bX0=
9Z1HGi7eip6GdQezqy3Vc7Er76ZgTfryda9wvHUgWzk=
```

The first key (stdout) is your private key, and the second key (stderr) is your public key. Keep the private key safe, and note down the public key for the next step.
The interactive setup asks for a unique node ID and other common settings.
It writes the private key to a user-readable-only config file and prints the
public key for the central configuration. Advanced network, DNS,
distribution, and hook settings are available from the same setup flow.

:::tip
If you already have a WireGuard keypair, you can use that interchangeably with nylon.
For automated setup, pass the node ID and any other settings as flags, such
as `nylon init --id node-1`. Pass an existing WireGuard private key with
`--key`, or use `nylon key` when you only need to generate a keypair.
:::

2. ### Create Node Configuration

On each node, create a `node.yaml` file. Replace `<YOUR_PRIVATE_KEY>` with the private key generated in step 1.
The generated file contains:

```yaml title="node.yaml"
id: node-1 # Give each node a unique ID (e.g., node-1, node-2)
key: <YOUR_PRIVATE_KEY>
key: <GENERATED_PRIVATE_KEY>
id: node-1
port: 57175
```

3. ### Create Central Configuration
2. ### Create Central Configuration

The `central.yaml` file defines the topology of your network. Create one file and share it across all nodes.

Expand Down Expand Up @@ -109,4 +105,4 @@ The Linux and macOS versions are well tested, but the Windows TUN interface has
- Setup nylon without a static public IP using [Dynamic DNS & Port Forwarding](/guides/port-forward).
- Monitor your nodes with [Prometheus metrics and health checks](/guides/observability).
{/* TODO: Advanced Routing guide (Anycast, Prefix Healthchecks) */}
{/* TODO: Monitoring and Debugging guide */}
{/* TODO: Monitoring and Debugging guide */}
8 changes: 8 additions & 0 deletions state/serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package state
import (
"encoding/base64"
"fmt"

"github.com/encodeous/nylon/polyamide/device"
)

func (k NyPrivateKey) MarshalText() ([]byte, error) {
Expand All @@ -16,6 +18,9 @@ func (k *NyPrivateKey) UnmarshalText(text []byte) error {
if err != nil {
return fmt.Errorf("failed to decode private key: %w", err)
}
if len(data) != device.NoisePrivateKeySize {
return fmt.Errorf("private key must decode to %d bytes, got %d", device.NoisePrivateKeySize, len(data))
}
*k = NyPrivateKey(data)
return nil
}
Expand All @@ -24,6 +29,9 @@ func (k *NyPublicKey) UnmarshalText(text []byte) error {
if err != nil {
return fmt.Errorf("failed to decode public key (%s): %w", text, err)
}
if len(data) != device.NoisePublicKeySize {
return fmt.Errorf("public key must decode to %d bytes, got %d", device.NoisePublicKeySize, len(data))
}
*k = NyPublicKey(data)
return nil
}
8 changes: 8 additions & 0 deletions state/serialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ port: abcd
err := yaml.Unmarshal([]byte(x1), &y1)
assert.ErrorContains(t, err, "cannot unmarshal string")
}

func TestDeserializeRejectsWrongKeyLength(t *testing.T) {
var privateKey NyPrivateKey
assert.ErrorContains(t, privateKey.UnmarshalText([]byte("c2hvcnQ=")), "must decode to 32 bytes")

var publicKey NyPublicKey
assert.ErrorContains(t, publicKey.UnmarshalText([]byte("c2hvcnQ=")), "must decode to 32 bytes")
}
Loading