summaryrefslogtreecommitdiff
path: root/internal/wireguard
diff options
context:
space:
mode:
Diffstat (limited to 'internal/wireguard')
-rw-r--r--internal/wireguard/ini/ini.go232
-rw-r--r--internal/wireguard/ini/ini_test.go325
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)
+ }
+ }
+}