diff --git a/easytier-go/README.md b/easytier-go/README.md index 8ff63f26..ad2e1aa3 100644 --- a/easytier-go/README.md +++ b/easytier-go/README.md @@ -55,6 +55,17 @@ connection, err := instance.Dial(ctx, "tcp4", "10.144.0.2:8080") packets, err := instance.ListenPacket("udp4", ":5353") ``` +`CreateInstanceTOML` loads a native EasyTier TOML document. Pass an empty +`instanceID` to allocate a UUID. Existing `instance_id` and `instance_name` +keys in the document are replaced by the host. + +```go +instance, err := host.CreateInstanceTOML(ctx, "office", "", configTOML) +``` + +`Instance.ShowNodeInfo` returns this instance's virtual IPv4 address and +advertised hostname. + ### Web Client management A host can also connect to an EasyTier Web configuration server. The embedded diff --git a/easytier-go/internal/host/host.go b/easytier-go/internal/host/host.go index a86fc861..4b5809f9 100644 --- a/easytier-go/internal/host/host.go +++ b/easytier-go/internal/host/host.go @@ -6,6 +6,9 @@ import ( "net" "net/netip" "strconv" + "strings" + + "github.com/EasyTier/EasyTier/easytier-go/proto/common" "github.com/EasyTier/EasyTier/easytier-go/internal/artifact" "github.com/EasyTier/EasyTier/easytier-go/internal/contextutil" @@ -132,6 +135,66 @@ func (host *Host) CreateInstance( return instance, nil } +// CreateInstanceTOML creates an EasyTier instance from a TOML configuration. +// instanceName is used as the instance_name field. If instanceID is empty, a +// new UUID is generated. Existing instance_id and instance_name keys in the +// TOML are replaced by bindInstanceIdentity. +func (host *Host) CreateInstanceTOML( + ctx context.Context, + instanceName string, + instanceID string, + configTOML string, +) (*Instance, error) { + if host == nil { + return nil, fmt.Errorf("create instance with nil EasyTier host") + } + if ctx == nil { + return nil, fmt.Errorf("create EasyTier instance with nil context") + } + if strings.TrimSpace(configTOML) == "" { + return nil, fmt.Errorf("EasyTier TOML configuration is empty") + } + if instanceName == "" { + instanceName = "easytier" + } + var ( + id *common.UUID + idString string + err error + ) + if instanceID != "" { + id, idString, err = parseInstanceUUID(instanceID) + } else { + id, idString, err = newInstanceUUID() + } + if err != nil { + return nil, err + } + runtime, err := host.engine.CreateInstance( + ctx, + bindInstanceIdentity(stripInstanceIdentity(configTOML), idString, instanceName), + ) + if err != nil { + return nil, err + } + instance := &Instance{ + engine: runtime, + id: idString, + manager: host.manager, + } + if err := host.manager.register(&managedInstance{ + id: id, + instance: instance, + owner: instanceOwnerApplication, + source: manage.ConfigSource_ConfigSourceUser, + name: instanceName, + }); err != nil { + _ = runtime.Close(contextutil.WithoutCancel(ctx)) + return nil, err + } + return instance, nil +} + // Instances returns a stable snapshot of all application- and Web-owned instances. func (host *Host) Instances() []*Instance { if host == nil || host.manager == nil { diff --git a/easytier-go/internal/host/instance_config_toml.go b/easytier-go/internal/host/instance_config_toml.go index 814b1df1..7a5e0acb 100644 --- a/easytier-go/internal/host/instance_config_toml.go +++ b/easytier-go/internal/host/instance_config_toml.go @@ -140,3 +140,21 @@ func quoteTOMLString(value string) string { } return string(encoded) } + +func stripInstanceIdentity(configTOML string) string { + var kept []string + for _, line := range strings.Split(configTOML, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "instance_id") || strings.HasPrefix(trimmed, "instance_name") { + key := trimmed + if idx := strings.IndexByte(trimmed, '='); idx >= 0 { + key = strings.TrimSpace(trimmed[:idx]) + } + if key == "instance_id" || key == "instance_name" { + continue + } + } + kept = append(kept, line) + } + return strings.TrimSpace(strings.Join(kept, "\n")) +} diff --git a/easytier-go/internal/host/instance_config_toml_test.go b/easytier-go/internal/host/instance_config_toml_test.go new file mode 100644 index 00000000..eea2ab59 --- /dev/null +++ b/easytier-go/internal/host/instance_config_toml_test.go @@ -0,0 +1,59 @@ +package host + +import ( + "strings" + "testing" +) + +func TestStripInstanceIdentityRemovesIdentityKeys(t *testing.T) { + input := `instance_id = "87ede5a2-9c3d-492d-9bbe-989b9d07e742" +instance_name = "old-name" +hostname = "node-a" +instance_identity = "keep" + +[network_identity] +network_name = "example" +` + got := stripInstanceIdentity(input) + if strings.Contains(got, "instance_id =") { + t.Fatalf("instance_id was not stripped:\n%s", got) + } + if strings.Contains(got, "instance_name =") { + t.Fatalf("instance_name was not stripped:\n%s", got) + } + if !strings.Contains(got, `hostname = "node-a"`) { + t.Fatalf("hostname was stripped:\n%s", got) + } + if !strings.Contains(got, `instance_identity = "keep"`) { + t.Fatalf("unrelated key was stripped:\n%s", got) + } + if !strings.Contains(got, "[network_identity]") { + t.Fatalf("network identity section was stripped:\n%s", got) + } +} + +func TestStripInstanceIdentityIgnoresCommentedAndPrefixedKeys(t *testing.T) { + input := `# instance_id = "commented" +instance_id_backup = "keep" +` + got := stripInstanceIdentity(input) + if !strings.Contains(got, `# instance_id = "commented"`) { + t.Fatalf("comment was stripped:\n%s", got) + } + if !strings.Contains(got, `instance_id_backup = "keep"`) { + t.Fatalf("prefixed key was stripped:\n%s", got) + } +} + +func TestBindInstanceIdentityPrependsCanonicalKeys(t *testing.T) { + got := bindInstanceIdentity("hostname = \"node\"\n", "11111111-2222-4333-8444-555555555555", "mihomo") + wantPrefix := `instance_id = "11111111-2222-4333-8444-555555555555" +instance_name = "mihomo" +` + if !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("bindInstanceIdentity() = %q, want prefix %q", got, wantPrefix) + } + if !strings.Contains(got, `hostname = "node"`) { + t.Fatalf("original config was lost: %q", got) + } +} diff --git a/easytier-go/internal/host/management.go b/easytier-go/internal/host/management.go index 9a492d66..9602f052 100644 --- a/easytier-go/internal/host/management.go +++ b/easytier-go/internal/host/management.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "encoding/hex" "errors" "fmt" "sort" @@ -710,6 +711,26 @@ func peerRoutePairs( return pairs } +func parseInstanceUUID(text string) (*common.UUID, string, error) { + normalized := strings.ReplaceAll(strings.TrimSpace(text), "-", "") + if len(normalized) != 32 { + return nil, "", fmt.Errorf("invalid EasyTier instance ID %q", text) + } + var value [16]byte + decoded, err := hex.DecodeString(normalized) + if err != nil || len(decoded) != 16 { + return nil, "", fmt.Errorf("invalid EasyTier instance ID %q", text) + } + copy(value[:], decoded) + id := &common.UUID{ + Part1: binary.BigEndian.Uint32(value[0:4]), + Part2: binary.BigEndian.Uint32(value[4:8]), + Part3: binary.BigEndian.Uint32(value[8:12]), + Part4: binary.BigEndian.Uint32(value[12:16]), + } + return id, uuidString(id), nil +} + func newInstanceUUID() (*common.UUID, string, error) { var value [16]byte if _, err := rand.Read(value[:]); err != nil { diff --git a/easytier-go/internal/host/rpc.go b/easytier-go/internal/host/rpc.go index 61edd1dc..b8c29c88 100644 --- a/easytier-go/internal/host/rpc.go +++ b/easytier-go/internal/host/rpc.go @@ -23,6 +23,9 @@ type PeerInfo = apiinstance.PeerInfo // Route describes one route visible to an EasyTier instance. type Route = apiinstance.Route +// NodeInfo describes this EasyTier instance. +type NodeInfo = apiinstance.NodeInfo + // ListPeer returns the peers visible to this EasyTier instance. func (instance *Instance) ListPeer( ctx context.Context, @@ -65,6 +68,19 @@ func (instance *Instance) listRouteResponse( return response, nil } +// ShowNodeInfo returns this instance's node information, including its +// virtual IPv4 address and advertised hostname. +func (instance *Instance) ShowNodeInfo(ctx context.Context) (*NodeInfo, error) { + response, err := instance.showNodeInfo(ctx) + if err != nil { + return nil, err + } + if response.NodeInfo == nil { + return nil, fmt.Errorf("EasyTier ShowNodeInfo returned no node info") + } + return response.NodeInfo, nil +} + func (instance *Instance) showNodeInfo( ctx context.Context, ) (*apiinstance.ShowNodeInfoResponse, error) { diff --git a/easytier-go/internal/host/uuid_test.go b/easytier-go/internal/host/uuid_test.go new file mode 100644 index 00000000..43118075 --- /dev/null +++ b/easytier-go/internal/host/uuid_test.go @@ -0,0 +1,53 @@ +package host + +import ( + "testing" +) + +func TestParseInstanceUUIDAcceptsCanonicalAndHex(t *testing.T) { + canonical := "87ede5a2-9c3d-492d-9bbe-989b9d07e742" + id, text, err := parseInstanceUUID(canonical) + if err != nil { + t.Fatalf("parse canonical UUID: %v", err) + } + if text != canonical { + t.Fatalf("canonical UUID string = %q, want %q", text, canonical) + } + if id == nil { + t.Fatal("parsed UUID was nil") + } + + _, compact, err := parseInstanceUUID("87EDE5A29C3D492D9BBE989B9D07E742") + if err != nil { + t.Fatalf("parse compact UUID: %v", err) + } + if compact != canonical { + t.Fatalf("compact UUID string = %q, want %q", compact, canonical) + } +} + +func TestParseInstanceUUIDRejectsInvalidValues(t *testing.T) { + for _, input := range []string{"", "not-a-uuid", "87ede5a2-9c3d-492d-9bbe", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"} { + if _, _, err := parseInstanceUUID(input); err == nil { + t.Fatalf("parseInstanceUUID(%q) succeeded", input) + } + } +} + +func TestNewInstanceUUIDRoundTrip(t *testing.T) { + id, text, err := newInstanceUUID() + if err != nil { + t.Fatalf("newInstanceUUID: %v", err) + } + parsed, parsedText, err := parseInstanceUUID(text) + if err != nil { + t.Fatalf("parse generated UUID: %v", err) + } + if parsedText != text { + t.Fatalf("round-trip UUID string = %q, want %q", parsedText, text) + } + if parsed.GetPart1() != id.GetPart1() || parsed.GetPart2() != id.GetPart2() || + parsed.GetPart3() != id.GetPart3() || parsed.GetPart4() != id.GetPart4() { + t.Fatalf("round-trip UUID parts = %+v, want %+v", parsed, id) + } +} diff --git a/easytier-go/rpc.go b/easytier-go/rpc.go index 673cad1b..c343826b 100644 --- a/easytier-go/rpc.go +++ b/easytier-go/rpc.go @@ -7,3 +7,6 @@ type PeerInfo = internalhost.PeerInfo // Route describes one route visible to an EasyTier instance. type Route = internalhost.Route + +// NodeInfo describes this EasyTier instance. +type NodeInfo = internalhost.NodeInfo