diff --git a/lib/properties.go b/lib/properties.go index 9e620b9..50f3899 100644 --- a/lib/properties.go +++ b/lib/properties.go @@ -26,11 +26,41 @@ func (c *Converter) parseJavaProperties(input []byte) (*TunnelConfig, error) { return config, nil } +// parsePropertyKey parses a single Java I2P property key-value pair and updates the TunnelConfig. +// Supports multiple Java I2P property patterns: +// +// Flat properties: +// - name, type, interface, listenPort, targetDestination, targetHost, description +// - proxyList, sharedClient, startOnLoad, accessList, targetPort, spoofedHost (stored in Tunnel map) +// - i2cpHost, i2cpPort (stored in I2CP map) +// +// Numbered tunnel patterns: +// - tunnel.N.property (e.g., tunnel.0.name, tunnel.1.type, tunnel.2.interface) +// +// Option prefixes: +// - option.i2cp.* -> stored in I2CP map +// - option.i2ptunnel.* -> stored in Tunnel map +// - option.inbound.* -> stored in Inbound map +// - option.outbound.* -> stored in Outbound map +// - option.persistentClientKey -> sets PersistentKey field +// +// Comments (#) and configFile properties are ignored. func (c *Converter) parsePropertyKey(k, s string, config *TunnelConfig) { if strings.HasPrefix(k, "#") || strings.HasPrefix(k, "configFile") { return // Skip comments and config file path } + // Handle tunnel.N.property patterns for numbered tunnels + if strings.HasPrefix(k, "tunnel.") && strings.Contains(k, ".") { + parts := strings.SplitN(k, ".", 3) + if len(parts) == 3 { + // Format: tunnel.N.property (e.g., tunnel.0.name, tunnel.1.type) + property := parts[2] + c.parseNumberedTunnelProperty(property, s, config) + return + } + } + // Handle flat keys switch k { case "name": @@ -47,8 +77,40 @@ func (c *Converter) parsePropertyKey(k, s string, config *TunnelConfig) { config.Target = s case "targetHost": config.Target = s // Alternative naming + case "targetPort": + if port, err := strconv.Atoi(s); err == nil { + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["targetPort"] = port + } case "description": config.Description = s + case "proxyList": + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["proxyList"] = parseValue(s) + case "sharedClient": + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["sharedClient"] = parseValue(s) + case "startOnLoad": + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["startOnLoad"] = parseValue(s) + case "accessList": + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["accessList"] = parseValue(s) + case "spoofedHost": + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["spoofedHost"] = parseValue(s) case "i2cpHost": if config.I2CP == nil { config.I2CP = make(map[string]interface{}) @@ -70,6 +132,84 @@ func (c *Converter) parsePropertyKey(k, s string, config *TunnelConfig) { } key := strings.TrimPrefix(k, "option.i2cp.") config.I2CP[key] = parseValue(s) + } else if strings.HasPrefix(k, "option.i2ptunnel.") { + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + key := strings.TrimPrefix(k, "option.i2ptunnel.") + config.Tunnel[key] = parseValue(s) + } else if strings.HasPrefix(k, "option.inbound.") { + if config.Inbound == nil { + config.Inbound = make(map[string]interface{}) + } + key := strings.TrimPrefix(k, "option.inbound.") + config.Inbound[key] = parseValue(s) + } else if strings.HasPrefix(k, "option.outbound.") { + if config.Outbound == nil { + config.Outbound = make(map[string]interface{}) + } + key := strings.TrimPrefix(k, "option.outbound.") + config.Outbound[key] = parseValue(s) + } else if k == "option.persistentClientKey" { + config.PersistentKey = parseValue(s).(bool) + } +} + +// parseNumberedTunnelProperty handles properties from tunnel.N.property patterns +// For numbered tunnel configurations, we treat each as a separate tunnel instance +// but for this converter we only support single tunnel configs, so we merge all numbered properties +func (c *Converter) parseNumberedTunnelProperty(property, value string, config *TunnelConfig) { + switch property { + case "name": + // If config.Name is empty, use this as the primary name + // If config.Name exists, treat as additional tunnel option + if config.Name == "" { + config.Name = value + } else { + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["alternateName"] = value + } + case "type": + if config.Type == "" { + config.Type = value + } + case "interface": + if config.Interface == "" { + config.Interface = value + } + case "listenPort": + if config.Port == 0 { + if port, err := strconv.Atoi(value); err == nil { + config.Port = port + } + } + case "targetDestination": + if config.Target == "" { + config.Target = value + } + case "targetHost": + if config.Target == "" { + config.Target = value + } + case "targetPort": + if port, err := strconv.Atoi(value); err == nil { + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel["targetPort"] = port + } + case "description": + if config.Description == "" { + config.Description = value + } + default: + // Store other numbered tunnel properties in the Tunnel map + if config.Tunnel == nil { + config.Tunnel = make(map[string]interface{}) + } + config.Tunnel[property] = parseValue(value) } } @@ -135,20 +275,36 @@ func (c *Converter) generateJavaProperties(config *TunnelConfig) ([]byte, error) } for k, v := range config.I2CP { - sb.WriteString(fmt.Sprintf("option.i2cp.%s=%v\n", k, v)) + sb.WriteString(fmt.Sprintf("option.i2cp.%s=%s\n", k, formatPropertyValue(v))) } for k, v := range config.Tunnel { - sb.WriteString(fmt.Sprintf("option.i2ptunnel.%s=%v\n", k, v)) + // Handle special flat properties that should not have option.i2ptunnel prefix + switch k { + case "proxyList", "sharedClient", "startOnLoad", "accessList", "spoofedHost", "targetPort": + sb.WriteString(fmt.Sprintf("%s=%s\n", k, formatPropertyValue(v))) + default: + // Other tunnel options use the option.i2ptunnel prefix + sb.WriteString(fmt.Sprintf("option.i2ptunnel.%s=%s\n", k, formatPropertyValue(v))) + } } for k, v := range config.Inbound { - sb.WriteString(fmt.Sprintf("option.inbound.%s=%v\n", k, v)) + sb.WriteString(fmt.Sprintf("option.inbound.%s=%s\n", k, formatPropertyValue(v))) } for k, v := range config.Outbound { - sb.WriteString(fmt.Sprintf("option.outbound.%s=%v\n", k, v)) + sb.WriteString(fmt.Sprintf("option.outbound.%s=%s\n", k, formatPropertyValue(v))) } return []byte(sb.String()), nil } + +// formatPropertyValue formats a property value for output +// Arrays/slices are formatted as comma-separated values +func formatPropertyValue(v interface{}) string { + if slice, ok := v.([]string); ok { + return strings.Join(slice, ",") + } + return fmt.Sprint(v) +} diff --git a/lib/properties_test.go b/lib/properties_test.go index c559e5a..b1292ae 100644 --- a/lib/properties_test.go +++ b/lib/properties_test.go @@ -1,6 +1,10 @@ package i2pconv -import "testing" +import ( + "fmt" + "reflect" + "testing" +) func TestPropertyConversion(t *testing.T) { input := ` @@ -26,7 +30,7 @@ sharedClient=true t.Fatalf("Failed to generate YAML: %v", err) } - // Expected YAML structure matching actual format: + // Expected YAML structure matching enhanced parsing: expected := `tunnels: I2P HTTP Proxy: name: I2P HTTP Proxy @@ -39,6 +43,11 @@ sharedClient=true - "4" - "0" reduceIdleTime: 900000 + options: + proxyList: exit.stormycloud.i2p + sharedClient: true + inbound: + length: 3 ` // Compare YAML output if string(yaml) != expected { @@ -47,3 +56,312 @@ sharedClient=true t.Logf("YAML output:\n%s", yaml) } } + +func TestEnhancedPropertiesParser(t *testing.T) { + tests := []struct { + name string + input string + expected *TunnelConfig + }{ + { + name: "numbered tunnel properties", + input: ` +tunnel.0.name=HTTPProxy +tunnel.0.type=httpclient +tunnel.0.interface=127.0.0.1 +tunnel.0.listenPort=4444 +tunnel.0.description=HTTP proxy tunnel +`, + expected: &TunnelConfig{ + Name: "HTTPProxy", + Type: "httpclient", + Interface: "127.0.0.1", + Port: 4444, + Description: "HTTP proxy tunnel", + I2CP: make(map[string]interface{}), + Tunnel: make(map[string]interface{}), + Inbound: make(map[string]interface{}), + Outbound: make(map[string]interface{}), + }, + }, + { + name: "option prefixes", + input: ` +name=TestTunnel +type=server +option.i2ptunnel.useCompression=true +option.inbound.length=3 +option.outbound.length=2 +option.persistentClientKey=true +`, + expected: &TunnelConfig{ + Name: "TestTunnel", + Type: "server", + PersistentKey: true, + I2CP: make(map[string]interface{}), + Tunnel: map[string]interface{}{ + "useCompression": true, + }, + Inbound: map[string]interface{}{ + "length": 3, + }, + Outbound: map[string]interface{}{ + "length": 2, + }, + }, + }, + { + name: "additional flat properties", + input: ` +name=WebServer +type=httpserver +proxyList=example.i2p,another.i2p +sharedClient=false +startOnLoad=true +accessList=allow +targetPort=8080 +spoofedHost=example.com +`, + expected: &TunnelConfig{ + Name: "WebServer", + Type: "httpserver", + I2CP: make(map[string]interface{}), + Tunnel: map[string]interface{}{ + "proxyList": []string{"example.i2p", "another.i2p"}, + "sharedClient": false, + "startOnLoad": true, + "accessList": "allow", + "targetPort": 8080, + "spoofedHost": "example.com", + }, + Inbound: make(map[string]interface{}), + Outbound: make(map[string]interface{}), + }, + }, + { + name: "mixed numbered and flat properties", + input: ` +tunnel.0.name=MixedTunnel +tunnel.1.type=client +interface=192.168.1.1 +listenPort=7070 +proxyList=proxy.i2p +option.i2cp.reduceIdleTime=600000 +`, + expected: &TunnelConfig{ + Name: "MixedTunnel", + Type: "client", + Interface: "192.168.1.1", + Port: 7070, + I2CP: map[string]interface{}{ + "reduceIdleTime": 600000, + }, + Tunnel: map[string]interface{}{ + "proxyList": "proxy.i2p", + }, + Inbound: make(map[string]interface{}), + Outbound: make(map[string]interface{}), + }, + }, + } + + conv := &Converter{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := conv.parseJavaProperties([]byte(tt.input)) + if err != nil { + t.Fatalf("Failed to parse properties: %v", err) + } + + // Check basic fields + if config.Name != tt.expected.Name { + t.Errorf("Name: expected %q, got %q", tt.expected.Name, config.Name) + } + if config.Type != tt.expected.Type { + t.Errorf("Type: expected %q, got %q", tt.expected.Type, config.Type) + } + if config.Interface != tt.expected.Interface { + t.Errorf("Interface: expected %q, got %q", tt.expected.Interface, config.Interface) + } + if config.Port != tt.expected.Port { + t.Errorf("Port: expected %d, got %d", tt.expected.Port, config.Port) + } + if config.Description != tt.expected.Description { + t.Errorf("Description: expected %q, got %q", tt.expected.Description, config.Description) + } + if config.PersistentKey != tt.expected.PersistentKey { + t.Errorf("PersistentKey: expected %t, got %t", tt.expected.PersistentKey, config.PersistentKey) + } + + // Check map fields using proper comparison + for k, v := range tt.expected.I2CP { + if actualV, ok := config.I2CP[k]; !ok { + t.Errorf("I2CP[%q]: missing key", k) + } else if !valuesEqual(v, actualV) { + t.Errorf("I2CP[%q]: expected %v, got %v", k, v, actualV) + } + } + for k, v := range tt.expected.Tunnel { + if actualV, ok := config.Tunnel[k]; !ok { + t.Errorf("Tunnel[%q]: missing key", k) + } else if !valuesEqual(v, actualV) { + t.Errorf("Tunnel[%q]: expected %v, got %v", k, v, actualV) + } + } + for k, v := range tt.expected.Inbound { + if actualV, ok := config.Inbound[k]; !ok { + t.Errorf("Inbound[%q]: missing key", k) + } else if !valuesEqual(v, actualV) { + t.Errorf("Inbound[%q]: expected %v, got %v", k, v, actualV) + } + } + for k, v := range tt.expected.Outbound { + if actualV, ok := config.Outbound[k]; !ok { + t.Errorf("Outbound[%q]: missing key", k) + } else if !valuesEqual(v, actualV) { + t.Errorf("Outbound[%q]: expected %v, got %v", k, v, actualV) + } + } + }) + } +} + +func TestPropertiesRoundTrip(t *testing.T) { + // Test that parsing and then generating produces consistent results + input := `name=RoundTripTest +type=httpclient +interface=127.0.0.1 +listenPort=8888 +targetDestination=example.i2p +description=Test tunnel for round-trip conversion +proxyList=proxy1.i2p,proxy2.i2p +sharedClient=true +startOnLoad=false +option.i2cp.leaseSetEncType=4,0 +option.i2cp.reduceIdleTime=900000 +option.i2ptunnel.useCompression=true +option.inbound.length=3 +option.outbound.length=2 +option.persistentClientKey=true` + + conv := &Converter{} + + // Parse properties + config, err := conv.parseJavaProperties([]byte(input)) + if err != nil { + t.Fatalf("Failed to parse properties: %v", err) + } + + // Generate properties back + output, err := conv.generateJavaProperties(config) + if err != nil { + t.Fatalf("Failed to generate properties: %v", err) + } + + // Parse the generated output again + config2, err := conv.parseJavaProperties(output) + if err != nil { + t.Fatalf("Failed to re-parse generated properties: %v", err) + } + + // Compare the two configs + if config.Name != config2.Name { + t.Errorf("Round-trip Name: expected %q, got %q", config.Name, config2.Name) + } + if config.Type != config2.Type { + t.Errorf("Round-trip Type: expected %q, got %q", config.Type, config2.Type) + } + if config.Interface != config2.Interface { + t.Errorf("Round-trip Interface: expected %q, got %q", config.Interface, config2.Interface) + } + if config.Port != config2.Port { + t.Errorf("Round-trip Port: expected %d, got %d", config.Port, config2.Port) + } + if config.Target != config2.Target { + t.Errorf("Round-trip Target: expected %q, got %q", config.Target, config2.Target) + } + if config.Description != config2.Description { + t.Errorf("Round-trip Description: expected %q, got %q", config.Description, config2.Description) + } + if config.PersistentKey != config2.PersistentKey { + t.Errorf("Round-trip PersistentKey: expected %t, got %t", config.PersistentKey, config2.PersistentKey) + } + + // Check that all options were preserved + for k, v := range config.I2CP { + if v2, ok := config2.I2CP[k]; !ok || fmt.Sprint(v) != fmt.Sprint(v2) { + t.Errorf("Round-trip I2CP[%q]: expected %v, got %v", k, v, v2) + } + } + + t.Logf("Round-trip successful. Generated properties:\n%s", string(output)) +} + +func TestPropertiesEdgeCases(t *testing.T) { + conv := &Converter{} + + // Test comments and config file paths are ignored + input := `# This is a comment +configFile=/path/to/file +name=TestTunnel +# Another comment +type=client` + + config, err := conv.ParseInput([]byte(input), "properties") + if err != nil { + t.Fatalf("Failed to parse properties: %v", err) + } + + if config.Name != "TestTunnel" { + t.Errorf("Expected name 'TestTunnel', got %q", config.Name) + } + if config.Type != "client" { + t.Errorf("Expected type 'client', got %q", config.Type) + } + + // Test alternate tunnel destination naming + input2 := `name=AltTunnel +type=server +targetHost=alt.example.i2p +i2cpHost=127.0.0.1 +i2cpPort=7654` + + config2, err := conv.ParseInput([]byte(input2), "properties") + if err != nil { + t.Fatalf("Failed to parse properties: %v", err) + } + + if config2.Target != "alt.example.i2p" { + t.Errorf("Expected target 'alt.example.i2p', got %q", config2.Target) + } + if config2.I2CP["host"] != "127.0.0.1" { + t.Errorf("Expected I2CP host '127.0.0.1', got %v", config2.I2CP["host"]) + } + if config2.I2CP["port"] != 7654 { + t.Errorf("Expected I2CP port 7654, got %v", config2.I2CP["port"]) + } + + // Test numbered tunnel conflicts (second values should be ignored when flat values exist) + input3 := `name=FirstName +tunnel.0.name=SecondName +type=client +tunnel.1.type=server` + + config3, err := conv.ParseInput([]byte(input3), "properties") + if err != nil { + t.Fatalf("Failed to parse properties: %v", err) + } + + // Should keep the first flat value + if config3.Name != "FirstName" { + t.Errorf("Expected name 'FirstName', got %q", config3.Name) + } + if config3.Type != "client" { + t.Errorf("Expected type 'client', got %q", config3.Type) + } +} + +// valuesEqual compares two values, handling slices properly +func valuesEqual(expected, actual interface{}) bool { + return reflect.DeepEqual(expected, actual) +}