diff options
| -rw-r--r-- | internal/wireguard/ini/ini.go | 232 | ||||
| -rw-r--r-- | internal/wireguard/ini/ini_test.go | 325 |
2 files changed, 557 insertions, 0 deletions
diff --git a/internal/wireguard/ini/ini.go b/internal/wireguard/ini/ini.go new file mode 100644 index 0000000..6791336 --- /dev/null +++ b/internal/wireguard/ini/ini.go @@ -0,0 +1,232 @@ +// package ini implements an opinionated ini parser that only implements what we exactly need for WireGuard configs +// - key/values MUST live under a section +// - empty section names are NOT allowed +// - comments are indicated with a # +package ini + +import ( + "errors" + "fmt" + "strings" +) + +// shouldSkip returns whether or not a line should be skipped, empty line (after whitespace omitting) or a comment +func shouldSkip(f string) bool { + return f == "" || strings.HasPrefix(f, "#") +} + +// isSection returns whether a line is a section +// this happens when it begins with [ and ends with ] +func isSection(f string) bool { + return strings.HasPrefix(f, "[") && strings.HasSuffix(f, "]") +} + +// sectionName extracts the section name from a line by removing the [ and ] prefix and suffix +func sectionName(f string) string { + name := strings.TrimSuffix(strings.TrimPrefix(f, "["), "]") + return strings.TrimSpace(name) +} + +// keyValue extracts a key and a value from a line +// if no 2 components are found (separated by =), we will return an error +// the key and value have their spaces trimmed +func keyValue(f string) (string, string, error) { + sl := strings.SplitN(f, "=", 2) + if len(sl) < 2 { + return "", "", errors.New("no key/value found") + } + k := strings.TrimSpace(sl[0]) + if k == "" { + return "", "", errors.New("key cannot be empty") + } + v := strings.TrimSpace(sl[1]) + return k, v, nil +} + +// OrderedKeys is a slice of strings that is used for an ordered map +type OrderedKeys []string + +func (ok *OrderedKeys) find(name string) int { + if ok == nil { + return -1 + } + for i, v := range *ok { + if v == name { + return i + } + } + return -1 +} + +// Remove removes a `name` from the OrderedKeys slice by finding the name +// It is a no-op if the key does not exist +func (ok *OrderedKeys) Remove(name string) { + idx := ok.find(name) + if idx == -1 { + return + } + *ok = append((*ok)[:idx], (*ok)[idx+1:]...) +} + +// Section represents a single section within an ini file +// It consists of multiple key and values +type Section struct { + keyValues map[string]string + keys OrderedKeys +} + +// KeyValue gets a value for key `key` +// It returns an error if the key does not exist +func (sec *Section) KeyValue(key string) (string, error) { + if v, ok := sec.keyValues[key]; ok { + return v, nil + } + return "", fmt.Errorf("key: '%s' does not exist", key) +} + +func (sec *Section) newKeyValue(key string, value string) { + if sec.keyValues == nil { + sec.keyValues = make(map[string]string) + } + sec.keyValues[key] = value + sec.keys = append(sec.keys, key) +} + +// AddOrReplaceKeyValue adds a key `key` with value `value` +// If the key already exists it modifies the value +func (sec *Section) AddOrReplaceKeyValue(key string, value string) { + _, err := sec.KeyValue(key) + if err == nil { + sec.keyValues[key] = value + return + } + sec.newKeyValue(key, value) +} + +// AddKeyValue adds a new key `key` with value `value` +// It returns an error if the key already exists +func (sec *Section) AddKeyValue(key string, value string) error { + // get an existing key + _, err := sec.KeyValue(key) + if err == nil { + return fmt.Errorf("key: '%s' already exists", key) + } + sec.newKeyValue(key, value) + return nil +} + +// RemoveKey removes a key `key` from the section +// It returns an error if the key cannot be found +func (sec *Section) RemoveKey(key string) (string, error) { + if v, ok := sec.keyValues[key]; ok { + sec.keys.Remove(key) + delete(sec.keyValues, key) + return v, nil + } + return "", fmt.Errorf("no key to remove with name: '%s'", key) +} + +// INI is the struct for a ini file +type INI struct { + sections map[string]*Section + keys OrderedKeys +} + +// Empty returns true if there are no sections defined in the INI +func (i *INI) Empty() bool { + return len(i.keys) == 0 +} + +// Section gets a section from the ini file +func (i *INI) Section(name string) (*Section, error) { + if _, ok := i.sections[name]; ok { + return i.sections[name], nil + } + return nil, fmt.Errorf("section: '%s' does not exist", name) +} + +// AddSection adds a section with name `name` and returns an error if the section already exists +func (i *INI) AddSection(name string) error { + // get an existing section + _, err := i.Section(name) + if err == nil { + return errors.New("section: '%s' already exists") + } + if i.sections == nil { + i.sections = make(map[string]*Section) + } + i.sections[name] = &Section{} + i.keys = append(i.keys, name) + return nil +} + +// String returns the representation of the ini as a string +func (i *INI) String() string { + var out strings.Builder + for _, s := range i.keys { + sec, err := i.Section(s) + if err != nil { + continue + } + out.WriteString(fmt.Sprintf("[%s]\n", s)) + + for _, k := range sec.keys { + v, err := sec.KeyValue(k) + if err != nil { + continue + } + delim := "" + if v != "" { + delim = " " + } + out.WriteString(fmt.Sprintf("%s =%s%s\n", k, delim, v)) + } + } + return out.String() +} + +// Parse returns a slice of sections +// we do not return a map as we want to ensure the same ordering of sections, keys and values +func Parse(f string) INI { + lines := strings.Split(f, "\n") + + var secs INI + sec := "" + for _, line := range lines { + // clean the line + line = strings.TrimSpace(line) + + if shouldSkip(line) { + continue + } + + if isSection(line) { + name := sectionName(line) + // we do not allow sections with empty names + if name == "" { + continue + } + _ = secs.AddSection(name) + sec = name + continue + } + + // no section has been parsed yet + // we will ignore the rest of the values + if sec == "" { + continue + } + + // split key and value + key, value, err := keyValue(line) + if err != nil { + continue + } + + csec := secs.sections[sec] + // This adds a new key and value to the section + // If it already exists it ignores it as this function would return an error + _ = csec.AddKeyValue(key, value) + } + return secs +} diff --git a/internal/wireguard/ini/ini_test.go b/internal/wireguard/ini/ini_test.go new file mode 100644 index 0000000..5382878 --- /dev/null +++ b/internal/wireguard/ini/ini_test.go @@ -0,0 +1,325 @@ +package ini + +import ( + "reflect" + "testing" + + "github.com/eduvpn/eduvpn-common/internal/test" +) + +func TestShouldSkip(t *testing.T) { + cases := []struct{ + in string + want bool + }{ + { + in: "test", + want: false, + }, + { + in: "#test", + want: true, + }, + { + in: "", + want: true, + }, + } + + for _, c := range cases { + g := shouldSkip(c.in) + if g != c.want { + t.Fatalf("got: %v, not equal to want: %v", g, c.want) + } + } +} + +func TestIsSection(t *testing.T) { + cases := []struct{ + in string + want bool + }{ + { + in: "[test]", + want: true, + }, + { + in: "#test", + want: false, + }, + { + in: "key=val", + want: false, + }, + // sections with empty names will be ignored later + { + in: "[]", + want: true, + }, + } + + for _, c := range cases { + g := isSection(c.in) + if g != c.want { + t.Fatalf("got: %v, not equal to want: %v", g, c.want) + } + } +} + +func TestSectionName(t *testing.T) { + cases := []struct{ + in string + want string + }{ + { + in: "[test]", + want: "test", + }, + { + in: "[ spaces ]", + want: "spaces", + }, + { + in: "[]", + want: "", + }, + { + in: "", + want: "", + }, + } + + for _, c := range cases { + g := sectionName(c.in) + if g != c.want { + t.Fatalf("got: %v, not equal to want: %v", g, c.want) + } + } +} + +func TestKeyValue(t *testing.T) { + cases := []struct{ + in string + wantk string + wantv string + wanterr string + }{ + { + in: "bla", + wantk: "", + wantv: "", + wanterr: "no key/value found", + }, + { + in: "foo=bar", + wantk: "foo", + wantv: "bar", + wanterr: "", + }, + { + in: " foo = bar ", + wantk: "foo", + wantv: "bar", + wanterr: "", + }, + { + in: "foo = bar", + wantk: "foo", + wantv: "bar", + wanterr: "", + }, + { + in: "", + wantk: "", + wantv: "", + wanterr: "no key/value found", + }, + { + in: "=", + wantk: "", + wantv: "", + wanterr: "key cannot be empty", + }, + { + in: "empty=", + wantk: "empty", + wantv: "", + wanterr: "", + }, + } + + for _, c := range cases { + gk, gv, gerr := keyValue(c.in) + test.AssertError(t, gerr, c.wanterr) + if gk != c.wantk { + t.Fatalf("key, got: %v, not equal to want: %v", gk, c.wantk) + } + if gv != c.wantv { + t.Fatalf("value, got: %v, not equal to want: %v", gv, c.wantv) + } + } +} + +func TestOrderedKeysFind(t *testing.T) { + cases := []struct{ + v OrderedKeys + in string + w int + }{ + { + v: []string{""}, + in: "test", + w: -1, + }, + { + v: []string{"bla"}, + in: "bla", + w: 0, + }, + { + v: []string{"ha"}, + in: "bla", + w: -1, + }, + { + v: []string{"ha", "ga"}, + in: "ga", + w: 1, + }, + } + + for _, c := range cases { + g := c.v.find(c.in) + if g != c.w { + t.Fatalf("got: %v, want: %v", g, c.w) + } + } +} + +func TestOrderedKeysRemove(t *testing.T) { + cases := []struct{ + v OrderedKeys + rem string + out OrderedKeys + }{ + { + v: []string{"bla"}, + rem: "test", + out: []string{"bla"}, + }, + { + v: []string{"bla"}, + rem: "bla", + out: []string{}, + }, + { + v: []string{"ha", "ga"}, + rem: "ga", + out: []string{"ha"}, + }, + } + + for _, c := range cases { + c.v.Remove(c.rem) + if !reflect.DeepEqual(c.v, c.out) { + t.Fatalf("got: %v, want: %v", c.v, c.out) + } + } +} + +func TestParse(t *testing.T) { + // parse correct file + + cases := []struct { + in string + want INI + }{ + { + in: ``, + want: INI{}, + }, + { + in: ` +[section1] +bla=val +`, + want: INI{ + sections: map[string]*Section{ + "section1": { + keyValues: map[string]string{ + "bla": "val", + }, + keys: []string{"bla"}, + }, + }, + keys: []string{"section1"}, + }, + }, + + { + in: ` +# Portal: https://vpn.tuxed.net/vpn-user-portal/ +# Profile: Default (default) +# Expires= 2025-01-23T15:56:58+00:00 + +[Interface] +MTU = 1392 +PrivateKey = wowsoprivate= +Address = 10.142.221.3/24,fdb6:645e:c74e:a648::3/64 +DNS = 9.9.9.9,2620:fe::fe + +[Peer] +PublicKey = whydidimockthisitspublic= +AllowedIPs = 0.0.0.0/0,::/0 +Endpoint = vpn.example.org:443 +`, + want: INI{ + sections: map[string]*Section{ + "Interface": { + keyValues: map[string]string{ + "MTU": "1392", + "PrivateKey": "wowsoprivate=", + "Address": "10.142.221.3/24,fdb6:645e:c74e:a648::3/64", + "DNS": "9.9.9.9,2620:fe::fe", + }, + keys: []string{"MTU", "PrivateKey", "Address", "DNS"}, + }, + "Peer": { + keyValues: map[string]string{ + "PublicKey": "whydidimockthisitspublic=", + "AllowedIPs": "0.0.0.0/0,::/0", + "Endpoint": "vpn.example.org:443", + }, + keys: []string{"PublicKey", "AllowedIPs", "Endpoint"}, + }, + }, + keys: []string{"Interface", "Peer"}, + }, + }, + { + in: ` +# Portal: https://vpn.tuxed.net/vpn-user-portal/ +# Profile: Default (default) +# Expires= 2025-01-23T15:56:58+00:00 + +MTU = 1392 +PrivateKey = wowsoprivate= +Address = 10.142.221.3/24,fdb6:645e:c74e:a648::3/64 +DNS = 9.9.9.9,2620:fe::fe + +PublicKey = whydidimockthisitspublic= +AllowedIPs = 0.0.0.0/0,::/0 +Endpoint = vpn.example.org:443 +`, + want: INI{}, + }, + } + + for i, v := range cases { + g := Parse(v.in) + + if !reflect.DeepEqual(g, v.want) { + t.Fatalf("failed deep equal case %d, got: %#v, want: %#v", i, g, v.want) + } + } +} |
