├── internal ├── config │ ├── test_data │ │ └── geteduroam │ │ │ ├── invalid.json │ │ │ ├── old.json │ │ │ └── valid.json │ ├── config_test.go │ └── config.go ├── eap │ ├── test_data │ │ ├── pkcs12invalid │ │ ├── genpkcs12.sh │ │ ├── eva-eap-changed.xml │ │ ├── pkcs12empty │ │ ├── pkcs12test │ │ └── eva-eap.xml │ └── eap_test.go ├── nm │ ├── README.md │ ├── base │ │ └── base.go │ ├── connection │ │ ├── connection.go │ │ └── settings.go │ └── nm.go ├── variant │ ├── geteduroam.go │ └── getgovroam.go ├── utils │ ├── utils_test.go │ └── utils.go ├── network │ ├── method │ │ ├── method_test.go │ │ └── method.go │ ├── inner │ │ ├── inner_test.go │ │ └── inner.go │ ├── cert │ │ ├── cert.go │ │ └── clientcert.go │ └── network.go ├── version │ └── version.go ├── notification │ ├── notification.go │ └── systemd │ │ └── systemd.go ├── log │ └── log.go ├── discovery │ └── discovery.go ├── handler │ └── handler.go └── provider │ ├── provider_test.go │ ├── provider.go │ └── profile.go ├── cmd ├── geteduroam-gui │ ├── resources │ │ ├── label.css │ │ ├── list.css │ │ ├── title.css │ │ ├── images │ │ │ ├── heart.png │ │ │ ├── success.png │ │ │ ├── geteduroam.png │ │ │ ├── getgovroam.png │ │ │ ├── govroam.svg │ │ │ ├── geteduroam.svg │ │ │ └── heart.svg │ │ ├── share │ │ │ ├── icons │ │ │ │ └── hicolor │ │ │ │ │ ├── 48x48 │ │ │ │ │ └── apps │ │ │ │ │ │ └── app.eduroam.geteduroam.png │ │ │ │ │ ├── 128x128 │ │ │ │ │ └── apps │ │ │ │ │ │ └── app.eduroam.geteduroam.png │ │ │ │ │ ├── 256x256 │ │ │ │ │ └── apps │ │ │ │ │ │ └── app.eduroam.geteduroam.png │ │ │ │ │ └── 512x512 │ │ │ │ │ └── apps │ │ │ │ │ └── app.eduroam.geteduroam.png │ │ │ └── applications │ │ │ │ └── app.eduroam.geteduroam.desktop │ │ ├── share_getgovroam │ │ │ ├── icons │ │ │ │ └── hicolor │ │ │ │ │ ├── 48x48 │ │ │ │ │ └── apps │ │ │ │ │ │ └── nl.govroam.getgovroam.png │ │ │ │ │ ├── 128x128 │ │ │ │ │ └── apps │ │ │ │ │ │ └── nl.govroam.getgovroam.png │ │ │ │ │ ├── 256x256 │ │ │ │ │ └── apps │ │ │ │ │ │ └── nl.govroam.getgovroam.png │ │ │ │ │ └── 512x512 │ │ │ │ │ └── apps │ │ │ │ │ └── nl.govroam.getgovroam.png │ │ │ └── applications │ │ │ │ └── nl.govroam.getgovroam.desktop │ │ ├── window.css.template │ │ ├── gears.ui │ │ └── main.ui │ ├── flatpak │ │ ├── start.png │ │ ├── success.png │ │ └── app.eduroam.geteduroam.metainfo.xml │ ├── resource.go │ ├── resource_default.go │ ├── resource_getgovroam.go │ ├── signal.go │ ├── dialog.go │ ├── loading.go │ ├── credentials.go │ ├── profile.go │ ├── cert.go │ ├── util.go │ ├── success.go │ ├── login.go │ ├── list.go │ └── main.go └── geteduroam-notifcheck │ └── main.go ├── systemd └── user │ ├── geteduroam │ ├── geteduroam-notifs.timer │ └── geteduroam-notifs.service │ └── getgovroam │ ├── getgovroam-notifs.timer │ └── getgovroam-notifs.service ├── .gitignore ├── nightly.sh ├── gpg_keys └── signer_key.asc ├── gencss.sh ├── go.mod ├── .github └── workflows │ ├── goreleaser.yml │ └── test.yml ├── LICENSE ├── Makefile ├── go.sum ├── README.md └── goreleaser.yml /internal/config/test_data/geteduroam/invalid.json: -------------------------------------------------------------------------------- 1 | "test" 2 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/label.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-size: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/list.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-size: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/title.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-size: 25px; 3 | } 4 | -------------------------------------------------------------------------------- /internal/config/test_data/geteduroam/old.json: -------------------------------------------------------------------------------- 1 | {"v1": {"uuid": "test"}} 2 | -------------------------------------------------------------------------------- /internal/config/test_data/geteduroam/valid.json: -------------------------------------------------------------------------------- 1 | {"v2": {"uuids": ["test"]}} 2 | -------------------------------------------------------------------------------- /internal/eap/test_data/pkcs12invalid: -------------------------------------------------------------------------------- 1 | ZmluZCBzb21ldGhpbmcgYmV0dGVyIHdpdGggeW91ciB0aW1lIEkgZ3Vlc3MK 2 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/flatpak/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/flatpak/start.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/flatpak/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/flatpak/success.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/images/heart.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/images/success.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/geteduroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/images/geteduroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/getgovroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/images/getgovroam.png -------------------------------------------------------------------------------- /systemd/user/geteduroam/geteduroam-notifs.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run geteduroam-notifs.service daily 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 12:00:00 6 | Persistent=true 7 | 8 | [Install] 9 | WantedBy=timers.target -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share/icons/hicolor/48x48/apps/app.eduroam.geteduroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share/icons/hicolor/48x48/apps/app.eduroam.geteduroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share/icons/hicolor/128x128/apps/app.eduroam.geteduroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share/icons/hicolor/128x128/apps/app.eduroam.geteduroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share/icons/hicolor/256x256/apps/app.eduroam.geteduroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share/icons/hicolor/256x256/apps/app.eduroam.geteduroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share/icons/hicolor/512x512/apps/app.eduroam.geteduroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share/icons/hicolor/512x512/apps/app.eduroam.geteduroam.png -------------------------------------------------------------------------------- /systemd/user/getgovroam/getgovroam-notifs.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run getgovroam-notifs.service daily 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 12:00:00 6 | Persistent=true 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resource.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func MustResource(name string) string { 4 | b, err := resources.ReadFile("resources/" + name) 5 | if err != nil { 6 | panic(err) 7 | } 8 | return string(b) 9 | } 10 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/48x48/apps/nl.govroam.getgovroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/48x48/apps/nl.govroam.getgovroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/128x128/apps/nl.govroam.getgovroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/128x128/apps/nl.govroam.getgovroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/256x256/apps/nl.govroam.getgovroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/256x256/apps/nl.govroam.getgovroam.png -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/512x512/apps/nl.govroam.getgovroam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geteduroam/linux-app/HEAD/cmd/geteduroam-gui/resources/share_getgovroam/icons/hicolor/512x512/apps/nl.govroam.getgovroam.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ./dist/ 2 | /internal/eap/test_data/pkcs12/ 3 | /builds/ 4 | /.direnv/ 5 | /.envrc 6 | /geteduroam-cli 7 | /geteduroam-gui 8 | /geteduroam-notifcheck 9 | /getgovroam-cli 10 | /getgovroam-gui 11 | /getgovroam-notifcheck 12 | /shell.nix 13 | -------------------------------------------------------------------------------- /internal/nm/README.md: -------------------------------------------------------------------------------- 1 | # NetworkManager package 2 | 3 | This package contains code (see the subpackages) that was based https://github.com/Wifx/gonetworkmanager (MIT License) 4 | 5 | However, it has been heavily modified to only contain the parts that we need 6 | -------------------------------------------------------------------------------- /systemd/user/geteduroam/geteduroam-notifs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=This service runs a geteduroam command that checks if the eduroam \ 3 | connection profile is about to expire and if so it shows a notification 4 | 5 | [Service] 6 | Type=oneshot 7 | ExecStart=/usr/bin/geteduroam-notifcheck -------------------------------------------------------------------------------- /systemd/user/getgovroam/getgovroam-notifs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=This service runs a getgovroam command that checks if the govroam \ 3 | connection profile is about to expire and if so it shows a notification 4 | 5 | [Service] 6 | Type=oneshot 7 | ExecStart=/usr/bin/getgovroam-notifcheck 8 | -------------------------------------------------------------------------------- /internal/eap/test_data/genpkcs12.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mkdir -p pkcs12 4 | openssl req -x509 -newkey rsa:4096 -keyout pkcs12/myKey.pem -out pkcs12/cert.pem -days 3650 -nodes 5 | 6 | openssl pkcs12 -export -out pkcs12/container.p12 -inkey pkcs12/myKey.pem -in pkcs12/cert.pem 7 | 8 | cat pkcs12/container.p12 | base64 9 | -------------------------------------------------------------------------------- /internal/variant/geteduroam.go: -------------------------------------------------------------------------------- 1 | //go:build !getgovroam 2 | 3 | package variant 4 | 5 | const ( 6 | AppID string = "app.eduroam.geteduroam" 7 | DiscoveryURL string = "https://discovery.eduroam.app/v3/discovery.json" 8 | DisplayName string = "geteduroam" 9 | ProfileName string = "eduroam" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/variant/getgovroam.go: -------------------------------------------------------------------------------- 1 | //go:build getgovroam 2 | 3 | package variant 4 | 5 | const ( 6 | AppID string = "nl.govroam.getgovroam" 7 | DiscoveryURL string = "https://discovery.getgovroam.nl/v3/discovery.json" 8 | DisplayName string = "getgovroam" 9 | ProfileName string = "govroam" 10 | ) 11 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resource_default.go: -------------------------------------------------------------------------------- 1 | //go:build !getgovroam 2 | 3 | package main 4 | 5 | import "embed" 6 | 7 | //go:embed resources/label.css resources/list.css resources/title.css resources/window_geteduroam.css resources/main.ui resources/gears.ui resources/images/success.png resources/images/heart.png 8 | var resources embed.FS 9 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resource_getgovroam.go: -------------------------------------------------------------------------------- 1 | //go:build getgovroam 2 | 3 | package main 4 | 5 | import "embed" 6 | 7 | //go:embed resources/label.css resources/list.css resources/title.css resources/window_getgovroam.css resources/main.ui resources/gears.ui resources/images/success.png resources/images/heart.png 8 | var resources embed.FS 9 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/window.css.template: -------------------------------------------------------------------------------- 1 | * { 2 | background-position: center, right 80%; 3 | background-repeat: no-repeat, no-repeat; 4 | /* base64 generated from resources/images/heart.png and resources/images/logo.png */ 5 | background-image: url('data:image/svg+xml;base64,$IMG_HEART'), url('data:image/png;base64,$IMG_LOGO'); 6 | } -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share_getgovroam/applications/nl.govroam.getgovroam.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Encoding=UTF-8 4 | Name=getgovroam 5 | Comment=A govroam client for Linux. Getgovroam simplifies the process of connecting to govroam. 6 | Exec=getgovroam-gui 7 | Icon=nl.govroam.getgovroam 8 | Terminal=false 9 | Categories=Network;Utility; -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/share/applications/app.eduroam.geteduroam.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Encoding=UTF-8 4 | Name=geteduroam 5 | Comment=A geteduroam client for Linux. Geteduroam simplifies the process of connecting to eduroam. 6 | Exec=geteduroam-gui 7 | Icon=app.eduroam.geteduroam 8 | Terminal=false 9 | Categories=Network;Utility; 10 | -------------------------------------------------------------------------------- /nightly.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf builds 4 | mkdir builds 5 | 6 | ARCHS=('arm' 'arm64' '386' 'amd64') 7 | for ARCH in "${ARCHS[@]}" 8 | do 9 | printf "compiling %s\n" "${ARCH}" 10 | CGO_ENABLED=0 GOOS="linux" GOARCH="${ARCH}" go build -trimpath=true -ldflags="-s -w" -o builds/geteduroam-cli-linux-"${ARCH}" ./cmd/geteduroam-cli 11 | done 12 | 13 | -------------------------------------------------------------------------------- /gpg_keys/signer_key.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mDMEZQr4fxYJKwYBBAHaRw8BAQdARMnQvgPEz7UHlu+bkOT692Y8kkzhn0cQe1GT 4 | cWGjgPO0SGdldGVkdXJvYW0gTGludXggY2xpZW50IGRldiBzaWduaW5nIGtleSA8 5 | Z2V0ZWR1cm9hbS1saW51eC1kZXZAZ2VhbnQub3JnPoiZBBMWCgBBFiEEFmNUo3yH 6 | y+KJulKFMgU/IWNuyTQFAmUK+H8CGwMFCRLMAwAFCwkIBwICIgIGFQoJCAsCBBYC 7 | AwECHgcCF4AACgkQMgU/IWNuyTSUAgD/X1aZ4s6U9Oj9jiSboP8BUnKReBr1Ctj8 8 | JUeTFjjgTacA/3e6EDPTeFCdlS45uHRIkdP1BImoyCuleyx9uvjnkoIL 9 | =pmE7 10 | -----END PGP PUBLIC KEY BLOCK----- -------------------------------------------------------------------------------- /gencss.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export IMG_HEART=$(cat ./cmd/geteduroam-gui/resources/images/heart.svg | base64 -w 0) 4 | # geteduroam 5 | export IMG_LOGO=$(cat ./cmd/geteduroam-gui/resources/images/geteduroam.png | base64 -w 0) 6 | envsubst < cmd/geteduroam-gui/resources/window.css.template > cmd/geteduroam-gui/resources/window_geteduroam.css 7 | # getgovroam 8 | export IMG_LOGO=$(cat ./cmd/geteduroam-gui/resources/images/getgovroam.png | base64 -w 0) 9 | envsubst < cmd/geteduroam-gui/resources/window.css.template > cmd/geteduroam-gui/resources/window_getgovroam.css 10 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/gears.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | _Import Metadata 7 | app.import-local 8 | 9 |
10 |
11 | 12 | _About 13 | app.about 14 | 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestRemoveDiacritics(t *testing.T) { 6 | cases := []struct { 7 | input string 8 | want string 9 | e error 10 | }{ 11 | { 12 | input: "foobar", 13 | want: "foobar", 14 | e: nil, 15 | }, 16 | { 17 | input: "fòóbår", 18 | want: "foobar", 19 | e: nil, 20 | }, 21 | } 22 | 23 | for _, c := range cases { 24 | result, e := RemoveDiacritics(c.input) 25 | if result != c.want || e != c.e { 26 | t.Fatalf("Result: %s, %v Want: %s, %v", result, e, c.want, c.e) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/geteduroam/linux-app 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/godbus/dbus/v5 v5.1.0 9 | github.com/jwijenbergh/eduoauth-go v1.1.2 10 | github.com/jwijenbergh/puregotk v0.0.0-20250407124134-bc1a52f44fd4 11 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 12 | golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b 13 | golang.org/x/sys v0.33.0 14 | golang.org/x/term v0.32.0 15 | golang.org/x/text v0.25.0 16 | software.sslmate.com/src/go-pkcs12 v0.5.0 17 | ) 18 | 19 | require ( 20 | github.com/jwijenbergh/purego v0.0.0-20241210143217-aeaa0bfe09e0 // indirect 21 | golang.org/x/crypto v0.38.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /internal/network/method/method_test.go: -------------------------------------------------------------------------------- 1 | package method 2 | 3 | import "testing" 4 | 5 | func TestIsValid(t *testing.T) { 6 | cases := []struct { 7 | input int 8 | want bool 9 | }{ 10 | { 11 | input: 0, 12 | want: false, 13 | }, 14 | { 15 | input: 67, 16 | want: false, 17 | }, 18 | { 19 | input: 13, 20 | want: true, 21 | }, 22 | { 23 | input: 21, 24 | want: true, 25 | }, 26 | { 27 | input: 25, 28 | want: true, 29 | }, 30 | { 31 | input: -13, 32 | want: false, 33 | }, 34 | } 35 | 36 | for _, c := range cases { 37 | got := IsValid(c.input) 38 | if got != c.want { 39 | t.Fatalf("Got: %v, Want: %v", got, c.want) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Import GPG Key 17 | run: echo "$GPG_PRIVATE_KEY" | gpg --batch --import 18 | env: 19 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 20 | - uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | - uses: goreleaser/goreleaser-action@v4 24 | with: 25 | distribution: goreleaser 26 | version: latest 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime/debug" 6 | ) 7 | 8 | // version is the current version 9 | const version = "0.12" 10 | 11 | // isReleased sets whether or not the current version is released yet 12 | const isReleased = true 13 | 14 | // Get gets the version in the following order: 15 | // - Gets a release version if it detects it is a release 16 | // - Gets the commit using debug info 17 | // - Returns a default 18 | func Get() string { 19 | if isReleased { 20 | return fmt.Sprintf("Version: %s", version) 21 | } 22 | if dbg, ok := debug.ReadBuildInfo(); ok { 23 | for _, s := range dbg.Settings { 24 | if s.Key == "vcs.revision" { 25 | return fmt.Sprintf("Dev version: %s with commit: %s", version, s.Value) 26 | } 27 | } 28 | } 29 | return fmt.Sprintf("Dev version: %s", version) 30 | } 31 | -------------------------------------------------------------------------------- /internal/nm/base/base.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "github.com/godbus/dbus/v5" 5 | ) 6 | 7 | const ( 8 | Interface = "org.freedesktop.NetworkManager" 9 | ObjectPath = "/org/freedesktop/NetworkManager" 10 | ) 11 | 12 | type Base struct { 13 | conn *dbus.Conn 14 | object dbus.BusObject 15 | } 16 | 17 | func (b *Base) Init(iface string, objectPath dbus.ObjectPath) error { 18 | var err error 19 | 20 | b.conn, err = dbus.SystemBus() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | b.object = b.conn.Object(iface, objectPath) 26 | 27 | return nil 28 | } 29 | 30 | func (b *Base) Call(method string, args ...interface{}) error { 31 | return b.object.Call(method, 0, args...).Err 32 | } 33 | 34 | func (b *Base) CallReturn(ret interface{}, method string, args ...interface{}) error { 35 | return b.object.Call(method, 0, args...).Store(ret) 36 | } 37 | 38 | func (b *Base) Path() dbus.ObjectPath { 39 | return b.object.Path() 40 | } 41 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/signal.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/jwijenbergh/puregotk/v4/gobject" 7 | ) 8 | 9 | // SignalPool is a collection of functions that will be called to cleanup all signals 10 | type SignalPool struct { 11 | mu sync.Mutex 12 | cleanup []func() 13 | } 14 | 15 | // AddSignal adds a signal for a puregotk object 16 | // It does this by adding a function that constructs a gobject from the pointer 17 | // And then disconnects the signal `t` 18 | func (p *SignalPool) AddSignal(ptr gobject.Ptr, t uint32) { 19 | p.mu.Lock() 20 | defer p.mu.Unlock() 21 | p.cleanup = append(p.cleanup, func() { 22 | var obj gobject.Object 23 | obj.SetGoPointer(ptr.GoPointer()) 24 | obj.DisconnectSignal(t) 25 | }) 26 | } 27 | 28 | // DisconnectSignals loops over the whole collection and calls the cleanup handler functions 29 | func (p *SignalPool) DisconnectSignals() { 30 | p.mu.Lock() 31 | defer p.mu.Unlock() 32 | for _, v := range p.cleanup { 33 | v() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/network/method/method.go: -------------------------------------------------------------------------------- 1 | package method 2 | 3 | // Type defines the EAP methods that are returned by the EAP xml 4 | type Type int 5 | 6 | const ( 7 | TLS Type = 13 8 | TTLS Type = 21 9 | PEAP Type = 25 10 | ) 11 | 12 | // IsValid returns whether or not an integer is a valid method type 13 | func IsValid(input int) bool { 14 | switch Type(input) { 15 | case TLS: 16 | return true 17 | case TTLS: 18 | return true 19 | case PEAP: 20 | return true 21 | } 22 | return false 23 | } 24 | 25 | func (m Type) String() string { 26 | switch m { 27 | case TLS: 28 | return "tls" 29 | case TTLS: 30 | return "ttls" 31 | case PEAP: 32 | return "peap" 33 | } 34 | return "" 35 | } 36 | 37 | // NeedsCredentials returns whether or not this method needs credentials (username/password) from the user 38 | func (m Type) NeedsCredentials() bool { 39 | return m != TLS 40 | } 41 | 42 | // NeedsCertificate returns whether or not this EAP method needs a client certificate 43 | // TODO: have a separate method that reports if the certificate needs a password 44 | func (m Type) NeedsCertificate() bool { 45 | return m == TLS 46 | } 47 | -------------------------------------------------------------------------------- /internal/nm/connection/connection.go: -------------------------------------------------------------------------------- 1 | package connection 2 | 3 | import ( 4 | "golang.org/x/exp/slog" 5 | 6 | "github.com/geteduroam/linux-app/internal/nm/base" 7 | "github.com/godbus/dbus/v5" 8 | ) 9 | 10 | const ( 11 | Interface = SettingsInterface + ".Connection" 12 | Delete = Interface + ".Delete" 13 | Update = Interface + ".Update" 14 | GetSettings = Interface + ".GetSettings" 15 | ) 16 | 17 | type Connection struct { 18 | base.Base 19 | } 20 | 21 | func New(path dbus.ObjectPath) (*Connection, error) { 22 | c := &Connection{} 23 | err := c.Init(base.Interface, path) 24 | if err != nil { 25 | slog.Debug("Error initiating DBus connection", "error", err) 26 | return nil, err 27 | } 28 | return c, nil 29 | } 30 | 31 | func (c *Connection) Update(settings SettingsArgs) error { 32 | return c.Call(Update, settings) 33 | } 34 | 35 | func (c *Connection) Delete() error { 36 | return c.Call(Delete) 37 | } 38 | 39 | func (c *Connection) GetSettings() (SettingsArgs, error) { 40 | var settings map[string]map[string]dbus.Variant 41 | 42 | if err := c.CallReturn(&settings, GetSettings); err != nil { 43 | return nil, err 44 | } 45 | 46 | return decodeSettings(settings), nil 47 | } 48 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/dialog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/jwijenbergh/puregotk/v4/gtk" 7 | ) 8 | 9 | type FileDialog struct { 10 | SignalPool 11 | *gtk.FileChooserDialog 12 | win *gtk.Window 13 | } 14 | 15 | func NewFileDialog(parent *gtk.Window, label string) (*FileDialog, error) { 16 | fc := gtk.NewFileChooserDialog( 17 | label, 18 | parent, 19 | gtk.FileChooserActionOpenValue, 20 | "Cancel", 21 | gtk.ResponseCancelValue, 22 | "Select", 23 | gtk.ResponseAcceptValue, 24 | 0, 25 | ) 26 | if fc == nil { 27 | return nil, errors.New("file chooser dialog could not be initialized") 28 | } 29 | var fwin gtk.Window 30 | fc.Cast(&fwin) 31 | return &FileDialog{ 32 | FileChooserDialog: fc, 33 | win: &fwin, 34 | }, nil 35 | } 36 | 37 | func (fd *FileDialog) Destroy() { 38 | fd.DisconnectSignals() 39 | fd.win.Destroy() 40 | } 41 | 42 | func (fd *FileDialog) Run(cb func(path string)) { 43 | rcb := func(_ gtk.Dialog, res int) { 44 | // TODO: int32 casting is a puregotk bug? gint should be int32 but I think it someties is a normal int 45 | if int32(res) == int32(gtk.ResponseAcceptValue) { 46 | f := fd.GetFile() 47 | cb(f.GetPath()) 48 | } 49 | fd.Destroy() 50 | } 51 | fd.AddSignal(fd, fd.ConnectResponse(&rcb)) 52 | fd.Present() 53 | } 54 | -------------------------------------------------------------------------------- /internal/notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "golang.org/x/exp/slog" 7 | 8 | "github.com/geteduroam/linux-app/internal/notification/systemd" 9 | "github.com/geteduroam/linux-app/internal/variant" 10 | ) 11 | 12 | // Send sends a single notification with notify-send 13 | func Send(msg string) error { 14 | _, err := exec.Command("notify-send", variant.DisplayName, msg).Output() 15 | return err 16 | } 17 | 18 | // HasDaemonSupport returns whether or not notifications can be enabled globally 19 | func HasDaemonSupport() bool { 20 | // currently we only support systemd 21 | return systemd.HasDaemonSupport() 22 | } 23 | 24 | // enableDaemon enables the notification using systemd's user daemon 25 | func enableDaemon() error { 26 | // currently we only support systemd 27 | return systemd.EnableDaemon() 28 | } 29 | 30 | // disableDaemon disables notifications when they were enabled 31 | func disableDaemon() error { 32 | return systemd.DisableDaemon() 33 | } 34 | 35 | // ConfigureDaemon configures the notification daemon 36 | // on if enable is true 37 | // else off 38 | // it logs if an error occurs 39 | func ConfigureDaemon(enable bool) { 40 | var err error 41 | if enable { 42 | err = enableDaemon() 43 | } else { 44 | err = disableDaemon() 45 | } 46 | if err != nil { 47 | slog.Error("failed to disable/enable notification support", "state", enable, "err", err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/loading.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jwijenbergh/puregotk/v4/adw" 5 | "github.com/jwijenbergh/puregotk/v4/gtk" 6 | ) 7 | 8 | type LoadingState struct { 9 | builder *gtk.Builder 10 | stack *adw.ViewStack 11 | spinner *gtk.Spinner 12 | Message string 13 | Cancel func() 14 | } 15 | 16 | func NewLoadingPage(builder *gtk.Builder, stack *adw.ViewStack, message string, cancel func()) *LoadingState { 17 | return &LoadingState{ 18 | builder: builder, 19 | stack: stack, 20 | Message: message, 21 | Cancel: cancel, 22 | } 23 | } 24 | 25 | func (l *LoadingState) Hide() { 26 | if l.spinner != nil { 27 | l.spinner.Stop() 28 | } 29 | } 30 | 31 | func (l *LoadingState) Initialize() { 32 | var page adw.ViewStackPage 33 | l.builder.GetObject("loadingPage").Cast(&page) 34 | defer page.Unref() 35 | var label gtk.Label 36 | l.builder.GetObject("loadingText").Cast(&label) 37 | defer label.Unref() 38 | label.SetText(l.Message) 39 | styleWidget(&label, "label") 40 | setPage(l.stack, &page) 41 | var spinner gtk.Spinner 42 | l.builder.GetObject("loadingSpinner").Cast(&spinner) 43 | defer spinner.Unref() 44 | l.spinner = &spinner 45 | 46 | var cancel gtk.Button 47 | l.builder.GetObject("loadingCancel").Cast(&cancel) 48 | defer cancel.Unref() 49 | if l.Cancel != nil { 50 | cancel.SetVisible(true) 51 | cb := func(_ gtk.Button) { 52 | l.Cancel() 53 | } 54 | cancel.ConnectClicked(&cb) 55 | } else { 56 | cancel.SetVisible(false) 57 | } 58 | 59 | spinner.Start() 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Jeroen Wijenbergh 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation and/or 11 | other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors 14 | may be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | .PHONY: help 3 | 4 | help: ## Print this help message 5 | @grep -E '^[\%a-zA-Z_-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | .DEFAULT_GOAL := help 8 | VARIANT := geteduroam 9 | ifneq ($(VARIANT),geteduroam) 10 | GOBUILDFLAGS := "-tags=$(VARIANT)" 11 | endif 12 | 13 | .build-notifcheck: 14 | go build $(GOBUILDFLAGS) -o $(VARIANT)-notifcheck ./cmd/geteduroam-notifcheck 15 | 16 | .build-cli: 17 | go build $(GOBUILDFLAGS) -o $(VARIANT)-cli ./cmd/geteduroam-cli 18 | 19 | .build-gui: 20 | go build $(GOBUILDFLAGS) -o $(VARIANT)-gui ./cmd/geteduroam-gui 21 | 22 | lint: ## Lint the codebase using Golangci-lint 23 | golangci-lint run -E stylecheck,revive,gocritic --timeout 5m 24 | 25 | fmt: ## Format the codebase using Gofumpt 26 | gofumpt -w . 27 | 28 | build-notifcheck: .build-notifcheck ## Build notification checker 29 | @echo "Done building, run 'make run-notifcheck' to run the notification checker" 30 | 31 | build-cli: .build-cli ## Build CLI version 32 | @echo "Done building, run 'make run-cli' to run the CLI" 33 | 34 | build-gui: .build-gui ## Build GUI version 35 | @echo "Done building, run 'make run-gui' to run the GUI" 36 | 37 | run-notifcheck: .build-notifcheck ## Run notification checker 38 | ./$(VARIANT)-notifcheck 39 | 40 | run-cli: .build-cli ## Run CLI version 41 | ./$(VARIANT)-cli 42 | 43 | run-gui: .build-gui ## Run GUI version 44 | ./$(VARIANT)-gui 45 | 46 | clean: ## Clean the project 47 | go clean 48 | rm -rf $(VARIANT)-notifcheck 49 | rm -rf $(VARIANT)-cli 50 | rm -rf $(VARIANT)-gui 51 | 52 | test: ## Run tests 53 | go test ./... 54 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/geteduroam/linux-app/internal/config" 9 | "github.com/geteduroam/linux-app/internal/utils" 10 | "golang.org/x/exp/slog" 11 | ) 12 | 13 | func Location(program string) (string, error) { 14 | logfile := fmt.Sprintf("%s.log", program) 15 | dir, err := config.Directory() 16 | if err != nil { 17 | return "", err 18 | } 19 | if err := os.MkdirAll(dir, 0o700); err != nil { 20 | return "", err 21 | } 22 | return filepath.Join(dir, logfile), nil 23 | } 24 | 25 | func newLogFile(program string) (*os.File, string, error) { 26 | fpath, err := Location(program) 27 | if err != nil { 28 | return nil, "", err 29 | } 30 | fp, err := os.OpenFile(fpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 31 | if err != nil { 32 | return nil, "", err 33 | } 34 | return fp, fpath, nil 35 | } 36 | 37 | // Initialize creates the logger from the program name and whether or not to enable debug logging 38 | // Logging is done to a file if possible, otherwise the console 39 | func Initialize(program string, debug bool) { 40 | logLevel := &slog.LevelVar{} 41 | opts := &slog.HandlerOptions{ 42 | Level: logLevel, 43 | } 44 | logfile, fpath, err := newLogFile(program) 45 | if err == nil { 46 | slog.SetDefault(slog.New(slog.NewTextHandler(logfile, opts))) 47 | if debug { 48 | fmt.Printf("Writing debug logs to %s\n", fpath) 49 | } else { 50 | utils.Verbosef("Writing logs to %s", fpath) 51 | } 52 | } else { 53 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, opts))) 54 | if debug { 55 | fmt.Println("Writing debug logs to console, due to error: ", err) 56 | } else { 57 | utils.Verbosef("Writing logs to console, due to error: ", err) 58 | } 59 | } 60 | if debug { 61 | logLevel.Set(slog.LevelDebug) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/credentials.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/geteduroam/linux-app/internal/network" 10 | "github.com/jwijenbergh/puregotk/v4/adw" 11 | "github.com/jwijenbergh/puregotk/v4/gtk" 12 | ) 13 | 14 | func NewCredentialsStateBase(builder *gtk.Builder, stack *adw.ViewStack, cred network.Credentials, pi network.ProviderInfo) *LoginBase { 15 | state := CredentialsState{ 16 | builder: builder, 17 | cred: cred, 18 | } 19 | return &LoginBase{ 20 | builder: builder, 21 | stack: stack, 22 | state: &state, 23 | pi: pi, 24 | wg: &sync.WaitGroup{}, 25 | } 26 | } 27 | 28 | type CredentialsState struct { 29 | builder *gtk.Builder 30 | cred network.Credentials 31 | 32 | user gtk.Entry 33 | pwd gtk.PasswordEntry 34 | } 35 | 36 | func (l *CredentialsState) Destroy() { 37 | l.user.Unref() 38 | l.pwd.Unref() 39 | } 40 | 41 | func (l *CredentialsState) Prefix() string { 42 | return "login" 43 | } 44 | 45 | func (l *CredentialsState) Get() (string, string) { 46 | return l.user.GetText(), l.pwd.GetText() 47 | } 48 | 49 | func (l *CredentialsState) Validate() error { 50 | ut := l.user.GetText() 51 | if ut == "" { 52 | return errors.New("username cannot be empty") 53 | } 54 | if !strings.HasPrefix(ut, l.cred.Prefix) { 55 | return fmt.Errorf("username must begin with: \"%s\"", l.cred.Prefix) 56 | } 57 | if !strings.HasSuffix(ut, l.cred.Suffix) { 58 | return fmt.Errorf("username must end with: \"%s\"", l.cred.Suffix) 59 | } 60 | if l.pwd.GetText() == "" { 61 | return errors.New("password cannot be empty") 62 | } 63 | return nil 64 | } 65 | 66 | func (l *CredentialsState) Initialize() { 67 | // TODO: Prefill suffix outside of text entry (so that it cannot be changed) 68 | // prefill password and username 69 | l.builder.GetObject("loginUsernameText").Cast(&l.user) 70 | l.user.SetText(l.cred.Prefix + l.cred.Suffix) 71 | 72 | l.builder.GetObject("loginPasswordText").Cast(&l.pwd) 73 | l.pwd.SetText(l.cred.Password) 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Test & check build" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | fmt-go: 7 | name: Check format Go 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/setup-go@v3 12 | with: 13 | go-version: ^1.18 14 | - uses: actions/checkout@v3 15 | - name: Install Gofumpt 16 | run: go install mvdan.cc/gofumpt@latest 17 | - name: Check fmt using Gofumpt 18 | run: | 19 | files=$(~/go/bin/gofumpt -l -d .); 20 | if [[ -n "$files" ]]; then 21 | printf '%s\nGofumpt would make changes, install Gofumpt and run "make lint"' "$files"; 22 | exit 1; 23 | fi 24 | shell: bash 25 | 26 | lint-go: 27 | name: Lint Go 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/setup-go@v3 32 | with: 33 | go-version: ^1.18 34 | - uses: actions/checkout@v3 35 | - name: Run golangci-lint 36 | uses: golangci/golangci-lint-action@v3 37 | # See https://github.com/golangci/golangci-lint-action/issues/485 for why we do --out-.... 38 | with: 39 | version: latest 40 | args: "-E stylecheck,revive,gocritic --out-${NO_FUTURE}format colored-line-number --timeout 5m" 41 | 42 | build-cli: 43 | name: Build CLI 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v3 48 | - uses: actions/setup-go@v3 49 | with: 50 | go-version: ^1.18 51 | - run: make build-cli 52 | 53 | build-gui: 54 | name: Build GUI 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - uses: actions/checkout@v3 59 | - uses: actions/setup-go@v3 60 | with: 61 | go-version: ^1.18 62 | - run: make build-gui 63 | 64 | test-go: 65 | name: Test Go 66 | runs-on: ubuntu-22.04 67 | steps: 68 | - uses: actions/checkout@v3 69 | - uses: actions/setup-go@v3 70 | with: 71 | go-version: ^1.18 72 | - run: make test -------------------------------------------------------------------------------- /cmd/geteduroam-gui/profile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "golang.org/x/exp/slog" 5 | 6 | "github.com/geteduroam/linux-app/internal/provider" 7 | "github.com/jwijenbergh/puregotk/v4/adw" 8 | "github.com/jwijenbergh/puregotk/v4/gtk" 9 | ) 10 | 11 | type ProfileState struct { 12 | builder *gtk.Builder 13 | stack *adw.ViewStack 14 | profiles []provider.Profile 15 | success func(provider.Profile) 16 | sl *SelectList 17 | } 18 | 19 | func NewProfileState(builder *gtk.Builder, stack *adw.ViewStack, profiles []provider.Profile, success func(provider.Profile)) *ProfileState { 20 | return &ProfileState{ 21 | builder: builder, 22 | stack: stack, 23 | profiles: profiles, 24 | success: success, 25 | } 26 | } 27 | 28 | func (p *ProfileState) Destroy() { 29 | p.sl.Destroy() 30 | } 31 | 32 | func (p *ProfileState) ShowError(err error) { 33 | slog.Error(err.Error(), "state", "profile") 34 | var overlay adw.ToastOverlay 35 | p.builder.GetObject("profileToastOverlay").Cast(&overlay) 36 | defer overlay.Unref() 37 | showErrorToast(overlay, err) 38 | } 39 | 40 | func (p *ProfileState) Initialize() { 41 | var page adw.ViewStackPage 42 | p.builder.GetObject("profilePage").Cast(&page) 43 | defer page.Unref() 44 | var scroll gtk.ScrolledWindow 45 | p.builder.GetObject("profileScroll").Cast(&scroll) 46 | defer scroll.Unref() 47 | var list gtk.ListView 48 | p.builder.GetObject("profileList").Cast(&list) 49 | defer list.Unref() 50 | 51 | var label gtk.Label 52 | p.builder.GetObject("profileLabel").Cast(&label) 53 | defer label.Unref() 54 | styleWidget(&label, "label") 55 | 56 | sorter := func(a, b int) int { 57 | // Here we have no search query 58 | return provider.SortNames(p.profiles[a].Name, p.profiles[b].Name, "") 59 | } 60 | activated := func(idx int) { 61 | go p.success(p.profiles[idx]) 62 | p.Destroy() 63 | } 64 | 65 | p.sl = NewSelectList(&scroll, &list, activated, sorter) 66 | 67 | for idx, prof := range p.profiles { 68 | p.sl.Add(idx, prof.Name.Get()) 69 | } 70 | 71 | p.sl.Setup() 72 | setPage(p.stack, &page) 73 | } 74 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 2 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 3 | github.com/jwijenbergh/eduoauth-go v1.1.2 h1:oNcytbMKan6KARZ/RMyje4Ax3LpCNsWogJNgyy9SucQ= 4 | github.com/jwijenbergh/eduoauth-go v1.1.2/go.mod h1:HidfCfBBI7U0edu2f0tNM/4/kkm4pD+nrp6IlANo214= 5 | github.com/jwijenbergh/purego v0.0.0-20241210143217-aeaa0bfe09e0 h1:T+RXKadM+FhXcAtRa5vL03gQkRkeqjasQQo/g0UqF1k= 6 | github.com/jwijenbergh/purego v0.0.0-20241210143217-aeaa0bfe09e0/go.mod h1:+ekdIOEa4gfAWPhy5blgdiyIJJez3H7o/9JVV01g0yo= 7 | github.com/jwijenbergh/puregotk v0.0.0-20250407124134-bc1a52f44fd4 h1:Mxyu7Mpr0iM8RoMYxxP2eQubiWa5FPMu7rO0Jbe+WLE= 8 | github.com/jwijenbergh/puregotk v0.0.0-20250407124134-bc1a52f44fd4/go.mod h1:5ggTnoOSeuCaDhJmBC2GOikLtyIgViqiQdW31d+rH2o= 9 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= 10 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 11 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 12 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 13 | golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA= 14 | golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 15 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 16 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 17 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 18 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 19 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 20 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 21 | software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= 22 | software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 23 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "time" 7 | "unicode" 8 | 9 | "golang.org/x/text/runes" 10 | "golang.org/x/text/transform" 11 | "golang.org/x/text/unicode/norm" 12 | ) 13 | 14 | // RemoveDiacritics removes "diacritics" :^) 15 | // Okay, diacritics are special characters, e.g. GÉANT, becomes GEANT 16 | // This is useful when using it for substring matching 17 | func RemoveDiacritics(text string) (string, error) { 18 | t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) 19 | result, _, err := transform.String(t, text) 20 | if err != nil { 21 | return "", err 22 | } 23 | return result, nil 24 | } 25 | 26 | // ErrorString returns an error message for an error 27 | // If the error is nil it returns the empty string 28 | func ErrorString(e error) string { 29 | if e == nil { 30 | return "" 31 | } 32 | return e.Error() 33 | } 34 | 35 | var IsVerbose bool 36 | 37 | // Conditionally (format) print verbose messages 38 | func Verbosef(msg string, args ...any) { 39 | if IsVerbose { 40 | fmt.Printf(msg+"\n", args...) 41 | } 42 | } 43 | 44 | // ValidityDays returns the amount of days left for the validity timestamp 45 | func ValidityDays(v time.Time) int { 46 | now := time.Now() 47 | if now.After(v) { 48 | return 0 49 | } 50 | days := v.Sub(now).Hours() / 24 51 | return int(math.Ceil(days)) 52 | } 53 | 54 | // DeltaTime gives a human readable output for a time difference 55 | // markb and marke mark the beginning and end markers, e.g. bold text 56 | func DeltaTime(d time.Duration, markb string, marke string) string { 57 | n := int(d.Seconds()) 58 | mins := n / 60 59 | secs := n % 60 60 | 61 | minText := "minutes" 62 | secText := "seconds" 63 | if mins == 1 { 64 | minText = "minute" 65 | } 66 | if secs == 1 { 67 | secText = "second" 68 | } 69 | 70 | switch { 71 | case mins > 0 && secs > 0: 72 | return fmt.Sprintf("%s%d%s %s and %s%d%s %s", markb, mins, marke, minText, markb, secs, marke, secText) 73 | 74 | case mins > 0: 75 | return fmt.Sprintf("%s%d%s %s", markb, mins, marke, minText) 76 | default: 77 | return fmt.Sprintf("%s%d%s %s", markb, secs, marke, secText) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/notification/systemd/systemd.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/geteduroam/linux-app/internal/variant" 10 | "golang.org/x/exp/slog" 11 | ) 12 | 13 | func hasSystemd() bool { 14 | var err error 15 | if _, err = os.Stat("/run/systemd/system"); err == nil { 16 | return true 17 | } else if errors.Is(err, os.ErrNotExist) { 18 | return false 19 | } 20 | slog.Error("failed to determine if system has systemd support", "error", err) 21 | return false 22 | } 23 | 24 | func hasUnit(unit string) bool { 25 | _, err := exec.Command("systemctl", "--user", "list-unit-files", unit).Output() 26 | return err == nil 27 | } 28 | 29 | const timerName string = variant.DisplayName + "-notifs.timer" 30 | 31 | func hasUnitFiles() bool { 32 | sn := variant.DisplayName + "-notifs.service" 33 | if !hasUnit(sn) { 34 | slog.Error(fmt.Sprintf("%s is not installed anywhere", sn)) 35 | return false 36 | } 37 | if !hasUnit(timerName) { 38 | slog.Error(fmt.Sprintf("%s is not installed anywhere", timerName)) 39 | return false 40 | } 41 | return true 42 | } 43 | 44 | // HasDaemonSupport returns whether or not notifications can be enabled globally 45 | // This depends on if systemd is used and if the unit is ready to be enabled 46 | func HasDaemonSupport() bool { 47 | if !hasSystemd() { 48 | return false 49 | } 50 | if !hasUnitFiles() { 51 | return false 52 | } 53 | return true 54 | } 55 | 56 | // EnableDaemon enables the notification daemon using systemctl commands 57 | func EnableDaemon() error { 58 | _, err := exec.Command("systemctl", "--user", "enable", "--now", timerName).Output() 59 | return err 60 | } 61 | 62 | // DisableDaemon disables the notification daemon using systemctl commands 63 | func DisableDaemon() error { 64 | _, err := exec.Command("systemctl", "--user", "is-enabled", timerName).Output() 65 | // when the timer is not enabled, return nil error and log 66 | if err != nil { 67 | slog.Debug("systemd reports timer is not enabled", "err", err) 68 | return nil 69 | } 70 | // timer is enabled 71 | // disable it 72 | _, err = exec.Command("systemctl", "--user", "disable", "--now", timerName).Output() 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/geteduroam/linux-app/internal/utils" 10 | ) 11 | 12 | func mockDir(t *testing.T, dir string) { 13 | // mock XDG_DATA_HOME 14 | err := os.Setenv("XDG_DATA_HOME", dir) 15 | if err != nil { 16 | t.Fatalf("failed setting environment for XDG_DATA_HOME: %v", err) 17 | } 18 | } 19 | 20 | func TestWrite(t *testing.T) { 21 | // create a test dir 22 | // this will be cleaned up when the test finishes, neat! 23 | dir := t.TempDir() 24 | mockDir(t, dir) 25 | c := &Config{ 26 | UUIDs: []string{"test"}, 27 | } 28 | err := c.Write() 29 | if err != nil { 30 | t.Fatalf("error occurred when writing config: %v", err) 31 | } 32 | 33 | cdir, err := Directory() 34 | if err != nil { 35 | t.Fatalf("error occurred when getting config directory: %v", dir) 36 | } 37 | r, err := os.ReadFile(filepath.Join(cdir, configName)) 38 | if err != nil { 39 | t.Fatalf("failed when reading config file: %v", err) 40 | } 41 | got := string(r) 42 | want := `{"v2":{"uuids":["test"]}}` 43 | if got != want { 44 | t.Fatalf("config not as expected, got: %v, want: %v", got, want) 45 | } 46 | } 47 | 48 | func TestLoad(t *testing.T) { 49 | mockDir(t, "test_data") 50 | cases := []struct { 51 | filename string 52 | wantc *Config 53 | wanterr string 54 | }{ 55 | { 56 | filename: "invalid.json", 57 | wantc: nil, 58 | wanterr: "json: cannot unmarshal string into Go value of type config.Versioned", 59 | }, 60 | { 61 | filename: "old.json", 62 | wantc: &Config{ 63 | UUIDs: []string{"test"}, 64 | }, 65 | wanterr: "", 66 | }, 67 | { 68 | filename: "valid.json", 69 | wantc: &Config{ 70 | UUIDs: []string{"test"}, 71 | }, 72 | wanterr: "", 73 | }, 74 | } 75 | 76 | for _, curr := range cases { 77 | // mock the config name 78 | configName = curr.filename 79 | gotc, goterr := Load() 80 | if utils.ErrorString(goterr) != curr.wanterr { 81 | t.Fatalf("expected config error not equal to want, got: %v, want: %v", goterr, curr.wanterr) 82 | } 83 | // to compare structs we can use deepequal 84 | if !reflect.DeepEqual(gotc, curr.wantc) { 85 | t.Fatalf("expected config not equal to want, got: %v, want: %v", gotc, curr.wantc) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/network/inner/inner_test.go: -------------------------------------------------------------------------------- 1 | package inner 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/geteduroam/linux-app/internal/network/method" 7 | ) 8 | 9 | func TestIsValid(t *testing.T) { 10 | cases := []struct { 11 | mt method.Type 12 | input int 13 | eap bool 14 | want bool 15 | }{ 16 | // We have an eap type but the inner is not an eap type 17 | { 18 | input: 3, // 3: PAP 19 | eap: true, 20 | want: false, 21 | }, 22 | // We have as method TLS, we should accept anything as it's not used anyways 23 | { 24 | mt: method.TLS, 25 | input: 0, 26 | want: true, 27 | }, 28 | { 29 | mt: method.TLS, 30 | input: 3, 31 | want: true, 32 | }, 33 | { 34 | mt: method.TLS, 35 | input: 25, 36 | want: true, 37 | }, 38 | { 39 | mt: method.TLS, 40 | input: 50, 41 | want: true, 42 | }, 43 | // TTLS we support different types than with PEAP 44 | { 45 | mt: method.TTLS, 46 | input: 3, // 3: PAP 47 | eap: false, 48 | want: true, 49 | }, 50 | { 51 | mt: method.TTLS, 52 | input: 25, // 25: EAP_PEAP_MSCHAPV2 53 | eap: true, 54 | want: false, 55 | }, 56 | { 57 | mt: method.TTLS, 58 | input: 26, // 26: EAP_PEAP_MSCHAPV2 59 | eap: true, 60 | want: true, 61 | }, 62 | { 63 | mt: method.TTLS, 64 | input: 27, // 27: bogus 65 | eap: false, 66 | want: false, 67 | }, 68 | { 69 | mt: method.PEAP, 70 | input: 25, // 25: EAP_PEAP_MSCHAPV2 71 | eap: true, 72 | want: true, 73 | }, 74 | { 75 | mt: method.PEAP, 76 | input: 25, // 25: EAP_PEAP_MSCHAPV2 77 | eap: false, // EAP not matching 78 | want: false, 79 | }, 80 | { 81 | mt: method.PEAP, 82 | input: 26, // 25: EAP_MSCHAPV2 83 | eap: true, 84 | want: true, 85 | }, 86 | { 87 | mt: method.PEAP, 88 | input: 26, // 26: EAP_MSCHAPV2 89 | eap: false, // EAP not matching 90 | want: false, 91 | }, 92 | } 93 | 94 | for _, c := range cases { 95 | got := IsValid(c.mt, c.input, c.eap) 96 | if got != c.want { 97 | t.Fatalf("Got: %v, Want: %v, when testing method type: %v, input: %v, eap: %v", got, c.want, c.mt, c.input, c.eap) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /internal/network/inner/inner.go: -------------------------------------------------------------------------------- 1 | package inner 2 | 3 | import ( 4 | "github.com/geteduroam/linux-app/internal/network/method" 5 | ) 6 | 7 | // Type defines the inner authentication methods that are returned by the EAP xml 8 | type Type int 9 | 10 | // TODO: Should we split these in EAP and non-EAP instead? 11 | const ( 12 | None Type = 0 13 | Pap Type = 1 14 | Mschap Type = 2 15 | Mschapv2 Type = 3 16 | // TODO: remove this? https://github.com/geteduroam/windows-app/blob/f11f00dee3eb71abd38537e18881463f83b180d3/CHANGELOG.md?plain=1#L34 17 | EapPeapMschapv2 Type = 25 18 | EapMschapv2 Type = 26 19 | ) 20 | 21 | // EAP returns whether the type is an EAP inner type 22 | func (t Type) EAP() bool { 23 | switch t { 24 | case EapPeapMschapv2: 25 | return true 26 | case EapMschapv2: 27 | return true 28 | } 29 | return false 30 | } 31 | 32 | // String returns the string representation of the inner type 33 | func (t Type) String() string { 34 | switch t { 35 | case Pap: 36 | return "pap" 37 | case Mschap: 38 | return "mschap" 39 | case Mschapv2: 40 | fallthrough 41 | case EapPeapMschapv2: 42 | fallthrough 43 | case EapMschapv2: 44 | return "mschapv2" 45 | } 46 | return "" 47 | } 48 | 49 | // IsValid returns whether or not an integer is a valid inner authentication type 50 | // See https://github.com/geteduroam/geteduroam-sh/blob/54044773812502487ad0f68898cd6b9e110cb0f6/eap-config.sh#L55 51 | func IsValid(mt method.Type, input int, eap bool) bool { 52 | // For TLS we do not have any inner, any is valid 53 | if mt == method.TLS { 54 | return true 55 | } 56 | // Check if the inner is an EAP or NON EAP type 57 | // They should match with what we expect it to be 58 | // So for example if we pass an input and expect an EAP type, but the input is actually NON-EAP, we return false as it's not valid 59 | if Type(input).EAP() != eap { 60 | return false 61 | } 62 | // For TTLS, we support PAP, MSCHAP, MSCHAPv2 and EAP MSCHAPV2 63 | if mt == method.TTLS { 64 | switch Type(input) { 65 | case Pap: 66 | return true 67 | case Mschap: 68 | return true 69 | case Mschapv2: 70 | return true 71 | case EapMschapv2: 72 | return true 73 | } 74 | return false 75 | } 76 | // for PEAP, we only support EAP*MSCHAPV2 77 | if mt == method.PEAP { 78 | switch Type(input) { 79 | case EapPeapMschapv2: 80 | return true 81 | case EapMschapv2: 82 | return true 83 | } 84 | return false 85 | } 86 | return false 87 | } 88 | -------------------------------------------------------------------------------- /internal/network/cert/cert.go: -------------------------------------------------------------------------------- 1 | package cert 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/pem" 8 | "errors" 9 | ) 10 | 11 | // toPEM converts an x509 certificate to a PEM encoded block 12 | func toPEM(cert *x509.Certificate) []byte { 13 | return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) 14 | } 15 | 16 | // isRoot checks if a certificate is a root CA 17 | // by checking if the issuer and subject bytes equal and if the certificate is a CA 18 | func isRoot(cert *x509.Certificate) bool { 19 | return bytes.Equal(cert.RawIssuer, cert.RawSubject) && cert.IsCA 20 | } 21 | 22 | type Chain struct { 23 | Roots []*x509.Certificate 24 | Intermediates []*x509.Certificate 25 | } 26 | 27 | type Certs struct { 28 | chains map[string]*Chain 29 | } 30 | 31 | // ToPEM converts the certs to PEM by first converting the intermediate certificates 32 | // and then the root certificate 33 | func (c *Certs) ToPEM() (ret []byte) { 34 | // do all the chains one by one 35 | for _, chain := range c.chains { 36 | // First intermediate certificates 37 | for _, ic := range chain.Intermediates { 38 | ret = append(ret, toPEM(ic)...) 39 | } 40 | // Then the root certificates 41 | for _, root := range chain.Roots { 42 | ret = append(ret, toPEM(root)...) 43 | } 44 | } 45 | return ret 46 | } 47 | 48 | // New creates a Certs struct by decoding the data in base64 49 | // Note that Certs is guaranteed to be non-nil when there is no error 50 | func New(data []string) (*Certs, error) { 51 | chains := make(map[string]*Chain) 52 | loopChains := func(wantRoot bool) { 53 | for _, d := range data { 54 | b, err := base64.StdEncoding.DecodeString(d) 55 | if err != nil { 56 | continue 57 | } 58 | cert, err := x509.ParseCertificate(b) 59 | if err != nil { 60 | continue 61 | } 62 | isRoot := isRoot(cert) 63 | v, ok := chains[cert.Issuer.String()] 64 | if wantRoot && isRoot { 65 | if ok { 66 | v.Roots = append(v.Roots, cert) 67 | } else { 68 | chains[cert.Issuer.String()] = &Chain{Roots: []*x509.Certificate{cert}} 69 | } 70 | } else if !wantRoot && !isRoot && ok { 71 | v.Intermediates = append(v.Intermediates, cert) 72 | } 73 | } 74 | } 75 | // go through all root CAs 76 | loopChains(true) 77 | if len(chains) == 0 { 78 | return nil, errors.New("no root CA found") 79 | } 80 | // then get the intermediate CAs 81 | loopChains(false) 82 | return &Certs{ 83 | chains: chains, 84 | }, nil 85 | } 86 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/cert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "sync" 7 | 8 | "github.com/geteduroam/linux-app/internal/network" 9 | "github.com/jwijenbergh/puregotk/v4/adw" 10 | "github.com/jwijenbergh/puregotk/v4/gtk" 11 | ) 12 | 13 | func NewCertificateStateBase(win *gtk.Window, builder *gtk.Builder, stack *adw.ViewStack, cert string, passphrase string, pi network.ProviderInfo) *LoginBase { 14 | state := CertificateState{ 15 | win: win, 16 | builder: builder, 17 | cert: cert, 18 | passphrase: passphrase, 19 | } 20 | return &LoginBase{ 21 | builder: builder, 22 | stack: stack, 23 | state: &state, 24 | pi: pi, 25 | wg: &sync.WaitGroup{}, 26 | } 27 | } 28 | 29 | type CertificateState struct { 30 | SignalPool 31 | win *gtk.Window 32 | builder *gtk.Builder 33 | 34 | certPath string 35 | upload gtk.Button 36 | cert string 37 | passphrase string 38 | pwd gtk.PasswordEntry 39 | } 40 | 41 | func (l *CertificateState) Destroy() { 42 | l.DisconnectSignals() 43 | l.upload.Unref() 44 | l.pwd.Unref() 45 | } 46 | 47 | func (l *CertificateState) Prefix() string { 48 | return "certificate" 49 | } 50 | 51 | func (l *CertificateState) File() ([]byte, error) { 52 | if l.certPath == "" { 53 | return nil, errors.New("no certificate chosen") 54 | } 55 | return os.ReadFile(l.certPath) 56 | } 57 | 58 | func (l *CertificateState) Get() (string, string) { 59 | return l.cert, l.pwd.GetText() 60 | } 61 | 62 | func (l *CertificateState) Validate() error { 63 | f, err := l.File() 64 | // only make sure this error is set if we don't have a valid cert yet 65 | if err != nil && l.cert == "" { 66 | return err 67 | } 68 | if f != nil { 69 | l.cert = string(f) 70 | } 71 | return nil 72 | } 73 | 74 | func (l *CertificateState) Initialize() { 75 | l.builder.GetObject("certificateFileButton").Cast(&l.upload) 76 | var label gtk.Label 77 | l.builder.GetObject("certificateFileText").Cast(&label) 78 | if l.cert != "" { 79 | label.SetText("A certificate is already provided.\nEnter the passphrase to decrypt") 80 | l.upload.Hide() 81 | } 82 | 83 | clicked := func(_ gtk.Button) { 84 | // Create a file dialog 85 | fd, err := NewFileDialog(l.win, "Choose a PKCS12 client certificate") 86 | if err != nil { 87 | // TODO: handle error 88 | panic(err) 89 | } 90 | 91 | fd.Run(func(p string) { 92 | l.certPath = p 93 | label.SetText(l.certPath) 94 | }) 95 | } 96 | 97 | l.AddSignal(&l.upload, l.upload.ConnectClicked(&clicked)) 98 | 99 | l.builder.GetObject("certificatePassphraseText").Cast(&l.pwd) 100 | l.pwd.SetText(l.passphrase) 101 | } 102 | -------------------------------------------------------------------------------- /cmd/geteduroam-notifcheck/main.go: -------------------------------------------------------------------------------- 1 | //go:debug x509negativeserial=1 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "time" 8 | 9 | "golang.org/x/exp/slog" 10 | 11 | "github.com/geteduroam/linux-app/internal/config" 12 | "github.com/geteduroam/linux-app/internal/log" 13 | "github.com/geteduroam/linux-app/internal/nm" 14 | "github.com/geteduroam/linux-app/internal/notification" 15 | "github.com/geteduroam/linux-app/internal/variant" 16 | ) 17 | 18 | const usage = `Usage of %s: 19 | -h, --help Prints this help information 20 | 21 | This CLI binary is needed for periodically checking for validity and giving notifications when the eduroam connection profile added by %s is about to expire. 22 | It gives a warning 10 days before expiry, and then every day. You can schedule to start this binary daily yourself or rely on the built-in systemd user timer. 23 | You also need notify-send installed to send the actual notifications. 24 | 25 | Log file location: %s 26 | ` 27 | 28 | func hasValidProfile(uuids []string) bool { 29 | for _, uuid := range uuids { 30 | con, err := nm.PreviousCon(uuid) 31 | if err != nil { 32 | slog.Error("no connection with uuid", "uuid", uuid, "error", err) 33 | continue 34 | } 35 | if con == nil { 36 | slog.Error("connection is nil") 37 | continue 38 | } 39 | return true 40 | } 41 | return false 42 | } 43 | 44 | func main() { 45 | program := fmt.Sprintf("%s-notifcheck", variant.DisplayName) 46 | lpath, err := log.Location(program) 47 | if err != nil { 48 | lpath = "N/A" 49 | } 50 | flag.Usage = func() { fmt.Printf(usage, program, variant.DisplayName, lpath) } 51 | flag.Parse() 52 | log.Initialize(fmt.Sprintf("%s-notifcheck", variant.DisplayName), false) 53 | cfg, err := config.Load() 54 | if err != nil { 55 | slog.Error("no previous state", "error", err) 56 | return 57 | } 58 | if cfg.Validity == nil { 59 | slog.Info("validity is nil") 60 | return 61 | } 62 | if !hasValidProfile(cfg.UUIDs) { 63 | slog.Info("no valid profiles found") 64 | return 65 | 66 | } 67 | 68 | valid := *cfg.Validity 69 | now := time.Now() 70 | diff := valid.Sub(now) 71 | days := int(diff.Hours() / 24) 72 | 73 | var text string 74 | if days > 10 { 75 | slog.Info("the profile is still valid for more than 10 days", "days", days) 76 | return 77 | } 78 | if days < 0 { 79 | text = "profile is expired" 80 | } 81 | if days == 0 { 82 | text = "profile expires today" 83 | } 84 | if days > 0 { 85 | text = fmt.Sprintf("profile expires in %d days", days) 86 | } 87 | msg := fmt.Sprintf("Your eduroam %s. Re-run %s to renew the profile", text, variant.DisplayName) 88 | err = notification.Send(msg) 89 | if err != nil { 90 | slog.Error("failed to send notification", "error", err) 91 | return 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/jwijenbergh/puregotk/v4/adw" 10 | "github.com/jwijenbergh/puregotk/v4/gdkpixbuf" 11 | "github.com/jwijenbergh/puregotk/v4/glib" 12 | "github.com/jwijenbergh/puregotk/v4/gtk" 13 | 14 | "github.com/geteduroam/linux-app/internal/variant" 15 | ) 16 | 17 | type StyledWidget interface { 18 | GetStyleContext() *gtk.StyleContext 19 | } 20 | 21 | func styleWidget(widget StyledWidget, resName string) { 22 | provider := gtk.NewCssProvider() 23 | provider.LoadFromData(MustResource(resName+".css"), -1) 24 | sc := widget.GetStyleContext() 25 | sc.AddProvider(provider, 800) 26 | } 27 | 28 | func setPage(stack *adw.ViewStack, page *adw.ViewStackPage) { 29 | child := page.GetChild() 30 | child.SetMarginStart(10) 31 | child.SetMarginEnd(10) 32 | child.SetMarginTop(5) 33 | child.SetMarginBottom(5) 34 | stack.SetVisibleChild(child) 35 | } 36 | 37 | func upper(str string) string { 38 | return strings.ToUpper(str[:1]) + str[1:] 39 | } 40 | 41 | func showErrorToast(overlay adw.ToastOverlay, err error) { 42 | msg := upper(err.Error()) 43 | toast := adw.NewToast(glib.MarkupEscapeText(msg, -1)) 44 | toast.SetTimeout(5) 45 | overlay.AddToast(toast) 46 | } 47 | 48 | func bytesPixbuf(b []byte) (*gdkpixbuf.Pixbuf, error) { 49 | // TODO: do this without creating a temp file 50 | f, err := os.CreateTemp("/tmp", fmt.Sprintf("%s-pixbuf", variant.DisplayName)) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer os.Remove(f.Name()) 55 | _, err = f.Write(b) 56 | if err != nil { 57 | return nil, err 58 | } 59 | pb, err := gdkpixbuf.NewPixbufFromFile(f.Name()) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return pb, nil 64 | } 65 | 66 | func uiThread(cb func()) { 67 | var idlecb glib.SourceFunc 68 | idlecb = func(uintptr) bool { 69 | // unref so this callback does not take up any slots 70 | defer glib.UnrefCallback(&idlecb) //nolint:errcheck 71 | cb() 72 | 73 | // return false here means just run it once, not over and over again 74 | // see the docs for glib_idle_add 75 | return false 76 | } 77 | glib.IdleAdd(&idlecb, 0) 78 | } 79 | 80 | func uiTicker(d uint, cb func() bool) { 81 | var timecb glib.SourceFunc 82 | timecb = func(uintptr) bool { 83 | ret := cb() 84 | if !ret { 85 | // unref so this callback does not take up any slots 86 | defer glib.UnrefCallback(&timecb) //nolint:errcheck 87 | } 88 | return ret 89 | } 90 | glib.TimeoutAddSeconds(d, &timecb, 0) 91 | } 92 | 93 | func ensureContextError(ctx context.Context, err error) error { 94 | if err == nil { 95 | return nil 96 | } 97 | 98 | select { 99 | case <-ctx.Done(): 100 | return context.Canceled 101 | default: 102 | return err 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geteduroam Linux client 2 | 3 | This repository contains the source code for the geteduroam Linux client. Currently WIP. 4 | 5 | [![Get it on Flathub](https://flathub.org/api/badge?locale=en)](https://flathub.org/apps/app.eduroam.geteduroam) 6 | 7 | Note that currently it only works with NetworkManager. But support for e.g. wpa-supplicant and iwd is planned. 8 | 9 | # Install through DEB/RPM 10 | To install the client using official packages, go to the [GitHub 11 | releases page](https://github.com/geteduroam/linux-app/releases) and 12 | pick DEB or RPM. These files can be saved and double clicked in your 13 | file manager to install. 14 | 15 | # Manual install 16 | This section, explains the steps needed to manually build the client. We go over the CLI client and GUI client. 17 | We also have a small binary that is used for sending of notifications, which we will explain too. 18 | 19 | ## Dependencies 20 | - Go >= 1.18 21 | - Make 22 | - GTK >= 4.06 (for the GUI) 23 | - Libadwaita >= 1.1 (for the GUI) 24 | - libnotify (for notifications) 25 | - NetworkManager 26 | 27 | ## CLI 28 | To build the CLI client run: 29 | ```bash 30 | make build-cli 31 | ``` 32 | 33 | This outputs the CLI to `./geteduroam-cli`, move this to somewhere in your `$PATH`, e.g. `/usr/bin`. 34 | 35 | During development, the CLI can be build and run with the command: 36 | ```bash 37 | make run-cli 38 | ``` 39 | 40 | ## GUI 41 | To build the GUI client run: 42 | ```bash 43 | make build-gui 44 | ``` 45 | 46 | This outputs the GUI to `./geteduroam-gui`, move this to somewhere in your `$PATH`, e.g. `/usr/bin`. 47 | 48 | During development, the GUI can be build and run with the command: 49 | ```bash 50 | make run-gui 51 | ``` 52 | 53 | ## Notifications 54 | For eduroam profiles that use TLS client certificates, the client can 55 | warn for imminent expiry. As the geteduroam client is not always open, 56 | we provide Systemd user files that check daily for imminent 57 | expiry. These systemd user files run the `./cmd/geteduroam-notifcheck/` binary. 58 | 59 | To build this binary, run: 60 | ```bash 61 | make build-notifcheck 62 | ``` 63 | 64 | This outputs this binary to `./geteduroam-notifcheck`, move this somewhere in your `$PATH`, e.g. `/usr/bin/`. 65 | 66 | During development, the notifcheck binary build and run with the command: 67 | ```bash 68 | make run-notifcheck 69 | ``` 70 | 71 | To then set this up with Systemd, make sure that the 72 | `systemd/user` service and timer files are in a location that the 73 | systemd user daemon can find it. E.g. move them to `/etc/systemd/user` 74 | or `~/.config/systemd/user`. The DEB and RPM packages do this 75 | automatically. Note that when moving these files, make sure to reload systemd with `systemctl --user daemon-reload`. 76 | Also note that these files hard-code the path to your `geteduroam-notifcheck` binary, in the service files we assume it is in `/usr/bin/`. 77 | 78 | Contributions are welcome to support other daemons. 79 | # License 80 | [BSD 3](./LICENSE) 81 | -------------------------------------------------------------------------------- /internal/discovery/discovery.go: -------------------------------------------------------------------------------- 1 | // package discovery contains methods to parse the discovery format from https://discovery.eduroam.app/v3/discovery.json into providers 2 | package discovery 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "golang.org/x/exp/slog" 12 | 13 | "github.com/geteduroam/linux-app/internal/provider" 14 | "github.com/geteduroam/linux-app/internal/variant" 15 | ) 16 | 17 | // Discovery is the main structure that is used for unmarshalling the JSON 18 | type Discovery struct { 19 | Value Value `json:"http://letswifi.app/discovery#v3"` 20 | } 21 | 22 | type Value struct { 23 | Providers provider.Providers `json:"providers"` 24 | // See: https://github.com/geteduroam/windows-app/blob/22cd90f36031907c7174fbdc678edafaa627ce49/CHANGELOG.md#changed 25 | Seq int `json:"seq"` 26 | } 27 | 28 | // Cache is the cached discovery list 29 | // TODO: This should be read from disk so that the app can function when the discovery is offline 30 | type Cache struct { 31 | // Cached is the cached list of discovery 32 | Cached Discovery `json:"previous"` 33 | // LastUpdate is the last time we updated the cache 34 | LastUpdate time.Time `json:"updated"` 35 | } 36 | 37 | // NewCache creates a new cache struct 38 | func NewCache() *Cache { 39 | return &Cache{} 40 | } 41 | 42 | // ToUpdate returns whether or not we should update the cached list 43 | func (c *Cache) ToUpdate() bool { 44 | if c.LastUpdate.IsZero() { 45 | return true 46 | } 47 | // We update every hour 48 | u := c.LastUpdate.Add(1 * time.Hour) 49 | n := time.Now() 50 | return n.After(u) 51 | } 52 | 53 | // Providers gets the providers either from the cache or from scratch 54 | func (c *Cache) Providers() (*provider.Providers, error) { 55 | if !c.ToUpdate() { 56 | return &c.Cached.Value.Providers, nil 57 | } 58 | 59 | req, err := http.NewRequest("GET", variant.DiscoveryURL, nil) 60 | if err != nil { 61 | return &c.Cached.Value.Providers, err 62 | } 63 | 64 | // Do request 65 | client := http.Client{Timeout: 10 * time.Second} 66 | res, err := client.Do(req) 67 | if err != nil { 68 | slog.Debug("Error requesting discovery.json", "error", err) 69 | return &c.Cached.Value.Providers, err 70 | } 71 | defer res.Body.Close() 72 | 73 | body, err := io.ReadAll(res.Body) 74 | if err != nil { 75 | slog.Debug("Error reading discovery.json response", "error", err) 76 | return &c.Cached.Value.Providers, err 77 | } 78 | if res.StatusCode < 200 || res.StatusCode > 299 { 79 | return &c.Cached.Value.Providers, fmt.Errorf("status code is not 2xx for discovery. Status code: %v, body: %v", res.StatusCode, string(body)) 80 | } 81 | 82 | var d *Discovery 83 | err = json.Unmarshal(body, &d) 84 | if err != nil { 85 | slog.Debug("Error loading discovery.json", "error", err) 86 | return &c.Cached.Value.Providers, err 87 | } 88 | 89 | // Do not accept older versions 90 | // This happens if the cached version is higher 91 | if c.Cached.Value.Seq > d.Value.Seq { 92 | return &c.Cached.Value.Providers, fmt.Errorf("cached seq is higher") 93 | } 94 | 95 | c.Cached = *d 96 | c.LastUpdate = time.Now() 97 | return &d.Value.Providers, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/eap/test_data/eva-eap-changed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 25 8 | 9 | 10 | MIID0zCCArugAwIBAg... 11 | MIIDeTCCAv+gAwIBAg... 12 | MIIEMjCCAxqgAwIBAg... 13 | edu.nl 14 | 15 | 16 | anonymous@edu.nl 17 | edu.nl 18 | true 19 | 20 | 21 | 22 | 23 | 21 24 | 25 | 26 | MIID0zCCArugAwIBAg... 27 | MIIDeTCCAv+gAwIBAg... 28 | MIIEMjCCAxqgAwIBAg... 29 | edu.nl 30 | 31 | 32 | anonymous@edu.nl 33 | edu.nl 34 | true 35 | 36 | 37 | 38 | 26 39 | 40 | 41 | 42 | 43 | 44 | 21 45 | 46 | 47 | MIID0zCCArugAwIBAg... 48 | MIIDeTCCAv+gAwIBAg... 49 | MIIEMjCCAxqgAwIBAg... 50 | edu.nl 51 | 52 | 53 | anonymous@edu.nl 54 | edu.nl 55 | true 56 | 57 | 58 | 59 | 2 60 | 61 | 62 | 63 | 64 | 65 | 66 | 001bc50460 67 | 68 | 69 | 004096 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/flatpak/app.eduroam.geteduroam.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | app.eduroam.geteduroam 4 | 5 | geteduroam 6 | A geteduroam client for Linux. Geteduroam simplifies the process of connecting to eduroam 7 | https://www.geteduroam.app 8 | 9 | geteduroam 10 | 11 | 12 | MIT 13 | BSD-3-Clause 14 | 15 | 16 | pointing 17 | keyboard 18 | touch 19 | 20 | 21 | 22 | 23 |

24 | Geteduroam is a proposed extension of the NREN T&I services, aimed at increasing the update and use of eduroam by students and faculty members. The idea is to do away with the need for separate eduroam user accounts, and instead use national federated Id (through eduGAIN such as SURFconext, Swamid, FEIDE, HAKA, etc) to automatically generate eduroam login credentials in form of client certificates as needed. In addition, geteduroam will deliver apps for user devices that will automatically install and configure eduroam. 25 |

26 |

27 | Eduroam is seen as one of the most successful NREN services. eduroam service deployment is extensive, with nearly every campus in Europe having an eduroam Wi-Fi network. However, in terms of users who have eduroam installed correctly on their devices, the uptake is much less. This is believed to be the results of 1) users need a separate eduroam account, 2) users need to install and configure this account on their device, 3) the need to do so become apparent to users only when they are away from their home institution. 28 |

29 |

30 | At the time eduroam was developed, there was no simple way around this. Today, with the widespread adoption of federated Id and the global collaboration of such Id’s under eduGAIN, it is possible to develop what is essentially a more modern way on managing and deploying eduroam for users. This is the objective of the geteduroam project. 31 |

32 |
33 | 34 | app.eduroam.geteduroam.desktop 35 | 36 | 37 | https://raw.githubusercontent.com/geteduroam/linux-app/986c96313a766bf3baf7cc5f122e66ec49627206/cmd/geteduroam-gui/flatpak/start.png 38 | A simple GUI to login to eduroam 39 | 40 | 41 | https://raw.githubusercontent.com/geteduroam/linux-app/986c96313a766bf3baf7cc5f122e66ec49627206/cmd/geteduroam-gui/flatpak/success.png 42 | A NetworkManager profile will be created automatically 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | -------------------------------------------------------------------------------- /internal/nm/connection/settings.go: -------------------------------------------------------------------------------- 1 | package connection 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/geteduroam/linux-app/internal/nm/base" 8 | "github.com/godbus/dbus/v5" 9 | ) 10 | 11 | const ( 12 | SettingsInterface = base.Interface + ".Settings" 13 | SettingsObjectPath = base.ObjectPath + "/Settings" 14 | 15 | SettingsAddConnection = SettingsInterface + ".AddConnection" 16 | SettingsGetConnectionByUUID = SettingsInterface + ".GetConnectionByUuid" 17 | ) 18 | 19 | type SettingsArgs map[string]map[string]interface{} 20 | 21 | func (s SettingsArgs) UUID() (string, error) { 22 | c, ok := s["connection"] 23 | if !ok { 24 | return "", errors.New("no connection value in connection settings map") 25 | } 26 | uuid, ok := c["uuid"] 27 | if !ok { 28 | return "", errors.New("no uuid in connection map") 29 | } 30 | uuidS, ok := uuid.(string) 31 | if !ok { 32 | return "", fmt.Errorf("uuid is not a string: %T", uuid) 33 | } 34 | return uuidS, nil 35 | } 36 | 37 | func (s SettingsArgs) SSID() (string, error) { 38 | c, ok := s["802-11-wireless"] 39 | if !ok { 40 | return "", errors.New("no 802-11-wireless value in connection settings map") 41 | } 42 | ssid, ok := c["ssid"] 43 | if !ok { 44 | return "", errors.New("no SSID 802-11-wireless map") 45 | } 46 | ssidS, ok := ssid.([]byte) 47 | if !ok { 48 | return "", fmt.Errorf("SSID is not a []byte: %T", ssid) 49 | } 50 | return string(ssidS), nil 51 | } 52 | 53 | type Settings struct { 54 | base.Base 55 | } 56 | 57 | func NewSettings() (*Settings, error) { 58 | s := &Settings{} 59 | err := s.Init(base.Interface, SettingsObjectPath) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return s, nil 64 | } 65 | 66 | func (s *Settings) AddConnection(settings SettingsArgs) (*Connection, error) { 67 | var path dbus.ObjectPath 68 | err := s.CallReturn(&path, SettingsAddConnection, settings) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return New(path) 74 | } 75 | 76 | func (s *Settings) ConnectionByUUID(uuid string) (*Connection, error) { 77 | var path dbus.ObjectPath 78 | err := s.CallReturn(&path, SettingsGetConnectionByUUID, uuid) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return New(path) 84 | } 85 | 86 | func decodeSettings(input map[string]map[string]dbus.Variant) (settings SettingsArgs) { 87 | valueMap := SettingsArgs{} 88 | for key, data := range input { 89 | valueMap[key] = decode(data).(map[string]interface{}) 90 | } 91 | return valueMap 92 | } 93 | 94 | func decode(input interface{}) (value interface{}) { 95 | switch v := input.(type) { 96 | case dbus.Variant: 97 | return decode(v.Value()) 98 | case map[string]dbus.Variant: 99 | return decodeMap(v) 100 | case []dbus.Variant: 101 | return decodeArray(v) 102 | case []map[string]dbus.Variant: 103 | return decodeMapArray(v) 104 | default: 105 | return v 106 | } 107 | } 108 | 109 | func decodeArray(input []dbus.Variant) (value []interface{}) { 110 | for _, data := range input { 111 | value = append(value, decode(data)) 112 | } 113 | return 114 | } 115 | 116 | func decodeMapArray(input []map[string]dbus.Variant) (value []map[string]interface{}) { 117 | for _, data := range input { 118 | value = append(value, decodeMap(data)) 119 | } 120 | return 121 | } 122 | 123 | func decodeMap(input map[string]dbus.Variant) (value map[string]interface{}) { 124 | value = map[string]interface{}{} 125 | for key, data := range input { 126 | value[key] = decode(data) 127 | } 128 | return 129 | } 130 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/success.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/geteduroam/linux-app/internal/notification" 8 | "github.com/geteduroam/linux-app/internal/utils" 9 | "github.com/geteduroam/linux-app/internal/variant" 10 | "github.com/jwijenbergh/puregotk/v4/adw" 11 | "github.com/jwijenbergh/puregotk/v4/glib" 12 | "github.com/jwijenbergh/puregotk/v4/gtk" 13 | ) 14 | 15 | type SuccessState struct { 16 | builder *gtk.Builder 17 | parent *gtk.Window 18 | stack *adw.ViewStack 19 | vBeg *time.Time 20 | vEnd *time.Time 21 | isredirect bool 22 | } 23 | 24 | func NewSuccessState(builder *gtk.Builder, parent *gtk.Window, stack *adw.ViewStack, vBeg *time.Time, vEnd *time.Time, isredirect bool) *SuccessState { 25 | return &SuccessState{ 26 | builder: builder, 27 | parent: parent, 28 | stack: stack, 29 | vBeg: vBeg, 30 | vEnd: vEnd, 31 | isredirect: isredirect, 32 | } 33 | } 34 | 35 | func (s *SuccessState) Initialize() { 36 | var page adw.ViewStackPage 37 | s.builder.GetObject("successPage").Cast(&page) 38 | defer page.Unref() 39 | 40 | var title gtk.Label 41 | s.builder.GetObject("successTitle").Cast(&title) 42 | defer title.Unref() 43 | styleWidget(&title, "title") 44 | if s.isredirect { 45 | title.SetText("Follow the instructions at the link opened in your browser") 46 | } 47 | 48 | var logo gtk.Image 49 | s.builder.GetObject("successLogo").Cast(&logo) 50 | defer logo.Unref() 51 | res := MustResource("images/success.png") 52 | pb, err := bytesPixbuf([]byte(res)) 53 | if err == nil { 54 | logo.SetFromPixbuf(pb) 55 | logo.SetSizeRequest(64, 64) 56 | } 57 | 58 | var sub gtk.Label 59 | s.builder.GetObject("successSubTitle").Cast(&sub) 60 | defer sub.Unref() 61 | sub.SetVisible(!s.isredirect) 62 | sub.SetText(fmt.Sprintf("Your %s profile has been added", variant.ProfileName)) 63 | styleWidget(&sub, "label") 64 | 65 | var valid gtk.Label 66 | s.builder.GetObject("validityText").Cast(&valid) 67 | defer valid.Unref() 68 | validText := valid.GetText() 69 | if s.vBeg == nil { 70 | valid.Hide() 71 | valid.Unref() 72 | } else { 73 | uiTicker(1, func() bool { 74 | delta := time.Until(*s.vBeg) 75 | // We do not want to show on 0 seconds 76 | if delta >= 1*time.Second { 77 | valid.SetMarkup(fmt.Sprintf("Your profile will be valid in: %s", utils.DeltaTime(delta, "", ""))) 78 | valid.Show() 79 | return true 80 | } 81 | if s.vEnd != nil { 82 | valid.SetMarkup(fmt.Sprintf("%s %d days", validText, utils.ValidityDays(*s.vEnd))) 83 | } else { // not very realistic this happens, but in theory it could 84 | valid.SetMarkup("Your profile is valid") 85 | } 86 | valid.Show() 87 | valid.Unref() 88 | return false 89 | }) 90 | } 91 | 92 | // set the page as current 93 | setPage(s.stack, &page) 94 | 95 | if s.vEnd == nil { 96 | return 97 | } 98 | if !notification.HasDaemonSupport() { 99 | return 100 | } 101 | 102 | dialog := adw.NewMessageDialog(s.parent, "Enable notifications?", fmt.Sprintf("This connection profile will expire in %d days.\n\nDo you want to enable notifications that warn for imminent expiry using systemd?", utils.ValidityDays(*s.vEnd))) 103 | dialog.AddResponse("disable", "Disable") 104 | dialog.AddResponse("enable", "Enable") 105 | dialog.SetResponseAppearance("enable", adw.ResponseSuggestedValue) 106 | dialog.SetDefaultResponse("disable") 107 | dialog.SetCloseResponse("disale") 108 | 109 | var dialogcb func(adw.MessageDialog, string) 110 | dialogcb = func(_ adw.MessageDialog, response string) { 111 | defer glib.UnrefCallback(&dialogcb) //nolint:errcheck 112 | notification.ConfigureDaemon(response == "enable") 113 | } 114 | dialog.ConnectResponse(&dialogcb) 115 | dialog.Present() 116 | } 117 | -------------------------------------------------------------------------------- /internal/network/cert/clientcert.go: -------------------------------------------------------------------------------- 1 | package cert 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/pem" 8 | "errors" 9 | "fmt" 10 | "log/slog" 11 | "time" 12 | 13 | "github.com/youmark/pkcs8" 14 | "software.sslmate.com/src/go-pkcs12" 15 | ) 16 | 17 | // ClientCert is the client certificate structure 18 | type ClientCert struct { 19 | // cert is the actual client certificate obtained from the PKCS12 container 20 | cert *x509.Certificate 21 | // privateKey is the RSA private key obtained from the PKCS12 container 22 | privateKey interface{} 23 | } 24 | 25 | // NewClientCert creates a new client certificate using the pkcs12 string 'pkcs12s' and passphrase 'pass' 26 | // It returns the client certificate object and an error itself 27 | func NewClientCert(pkcs12s string, pass string, b64 bool) (*ClientCert, error) { 28 | rawcc := []byte(pkcs12s) 29 | if b64 { 30 | var err error 31 | rawcc, err = base64.StdEncoding.DecodeString(pkcs12s) 32 | if err != nil { 33 | return nil, err 34 | } 35 | } 36 | // decode the PKCS12 container to get the client certificate 37 | pk, cc, _, err := pkcs12.DecodeChain(rawcc, pass) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | // do some basic checks on the validity bounds 43 | curr := time.Now() 44 | 45 | // Due to clock skew, it could be that the client time is too early 46 | // We allow a delta of 5 minutes 47 | const delta = 5 48 | if curr.Add(delta * time.Minute).Before(cc.NotBefore) { 49 | slog.Error("client certificate is used before Not Before + delta", "time", curr, "nb", cc.NotBefore) 50 | return nil, fmt.Errorf("client certificate is used before the 'Not Before' time with a delta of %d minutes", delta) 51 | } 52 | if curr.After(cc.NotAfter) { 53 | return nil, errors.New("client certificate is used after the 'Not After' time") 54 | } 55 | return &ClientCert{ 56 | cert: cc, 57 | privateKey: pk, 58 | }, nil 59 | } 60 | 61 | // genb64 creates a cryptographically random bytes slice of 32 bytes 62 | // This byte slice is then encoded to base64 63 | // It returns the byte slice encoded to base64 (or nil if error) and an error if it could not be generated. 64 | func genb64() (pwd string, err error) { 65 | n := 32 66 | bs := make([]byte, n) 67 | if _, err := rand.Read(bs); err != nil { 68 | return "", err 69 | } 70 | return base64.StdEncoding.EncodeToString(bs), nil 71 | } 72 | 73 | // PrivateKeyPEMEnc gets the private key in encrypted PEM format 74 | // It returns the PEM format for the private key, the password protecting it and an error 75 | // The password is automatically generated by generating random bytes using crypto/rand 76 | // ... and then feeding it to base64 encoding 77 | func (cc *ClientCert) PrivateKeyPEMEnc() (pemb []byte, pwd string, err error) { 78 | pwd, err = genb64() 79 | if err != nil { 80 | return nil, "", err 81 | } 82 | b, err := pkcs8.MarshalPrivateKey(cc.privateKey, []byte(pwd), nil) 83 | if err != nil { 84 | return nil, "", err 85 | } 86 | block := &pem.Block{ 87 | Type: "ENCRYPTED PRIVATE KEY", 88 | Bytes: b, 89 | } 90 | return pem.EncodeToMemory(block), pwd, nil 91 | } 92 | 93 | // ToPEM generates the PEM bytes for the client certificate 94 | func (cc *ClientCert) ToPEM() []byte { 95 | return toPEM(cc.cert) 96 | } 97 | 98 | // Validity returns starting at and until when the client certificate is valid 99 | // Before this time the certificate should thus be renewed by running the client again 100 | // It could be also that, due to clock skew, the cert is not valid yet, which is indicated by the first value 101 | func (cc *ClientCert) Validity() (time.Time, time.Time) { 102 | return cc.cert.NotBefore, cc.cert.NotAfter 103 | } 104 | 105 | // SubjectCN returns the CommonName for the certificate subject 106 | func (cc *ClientCert) SubjectCN() string { 107 | return cc.cert.Subject.CommonName 108 | } 109 | -------------------------------------------------------------------------------- /internal/handler/handler.go: -------------------------------------------------------------------------------- 1 | // package handlers handles the eduroam connection by parsing the byte array 2 | // It has handlers for UI events 3 | package handler 4 | 5 | import ( 6 | "time" 7 | 8 | "golang.org/x/exp/slog" 9 | 10 | "github.com/geteduroam/linux-app/internal/config" 11 | "github.com/geteduroam/linux-app/internal/eap" 12 | "github.com/geteduroam/linux-app/internal/network" 13 | "github.com/geteduroam/linux-app/internal/network/cert" 14 | "github.com/geteduroam/linux-app/internal/nm" 15 | ) 16 | 17 | // Handlers is the structure that holds the handlers for UI events 18 | // 'Handlers' are just functions that are called to get certain data 19 | type Handlers struct { 20 | // CredentialsH is the handler for asking for the username and password 21 | // c are the credentials which also contains prefixes and suffixes for the username 22 | // pi is the provider info 23 | // It returns the username and password that were filled in 24 | CredentialsH func(c network.Credentials, pi network.ProviderInfo) (string, string, error) 25 | 26 | // CertificateH is the handler for asking for the client certificate from the user 27 | // It returns the certificate, the passphrase and an error 28 | CertificateH func(cert string, passphrase string, pi network.ProviderInfo) (string, string, error) 29 | } 30 | 31 | // network gets the network by parsing the connection using the EAP byte array 32 | func (h Handlers) network(config []byte) (network.Network, error) { 33 | // First we parse the config 34 | unpack, err := eap.Parse(config) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | n, err := unpack.Network() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return n, nil 45 | } 46 | 47 | // Configure configures the connection using the parsed configuration 48 | // It installs it using NetworkManager 49 | func (h Handlers) Configure(eap []byte) (*time.Time, *time.Time, error) { 50 | // Get the network 51 | n, err := h.network(eap) 52 | if err != nil { 53 | return nil, nil, err 54 | } 55 | var uuids []string 56 | 57 | // get the previous UUID if the config can be loaded 58 | c, err := config.Load() 59 | if err == nil { 60 | uuids = c.UUIDs 61 | } 62 | 63 | var validFor *time.Time 64 | var validAt *time.Time 65 | switch t := n.(type) { 66 | case *network.NonTLS: 67 | if t.Credentials.Username == "" || t.Credentials.Password == "" { 68 | username, password, cerr := h.CredentialsH(t.Credentials, n.ProviderInfo()) 69 | if cerr != nil { 70 | slog.Debug("Error asking for credentials", "error", err) 71 | return nil, nil, cerr 72 | } 73 | t.Credentials.Username = username 74 | t.Credentials.Password = password 75 | } 76 | uuids, err = nm.Install(*t, uuids) 77 | case *network.TLS: 78 | // if a PKCS12 file is uploaded by the user we expect it to be not base64 encoded 79 | b64 := t.RawPKCS12 != "" 80 | // TODO: Loop until the PKCS12 can be decrypted successfully? 81 | if t.ClientCert == nil { 82 | ccert, passphrase, err := h.CertificateH(t.RawPKCS12, t.Password, n.ProviderInfo()) 83 | if err != nil { 84 | return nil, nil, err 85 | } 86 | // here the data is not base64 encoded 87 | t.ClientCert, err = cert.NewClientCert(ccert, passphrase, b64) 88 | if err != nil { 89 | return nil, nil, err 90 | } 91 | } 92 | vBeg, vEnd := t.Validity() 93 | validFor = &vEnd 94 | validAt = &vBeg 95 | uuids, err = nm.InstallTLS(*t, uuids) 96 | default: 97 | panic("unsupported network") 98 | } 99 | if err != nil { 100 | if len(uuids) == 0 { 101 | slog.Error("Error installing network", "error", err) 102 | return nil, nil, err 103 | } 104 | slog.Info("One of the networks failed to install", "error", err) 105 | } 106 | // save the config with the uuid 107 | nc := config.Config{ 108 | UUIDs: uuids, 109 | Validity: validFor, 110 | } 111 | err = nc.Write() 112 | if err != nil { 113 | slog.Debug("Error configuring network", "error", err) 114 | return nil, nil, err 115 | } 116 | return validAt, validFor, nil 117 | } 118 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | // package config has methods to write (TODO: read) config files 2 | package config 3 | 4 | import ( 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "golang.org/x/exp/slog" 11 | 12 | "github.com/geteduroam/linux-app/internal/utils" 13 | "github.com/geteduroam/linux-app/internal/variant" 14 | ) 15 | 16 | // Config is the main structure for the configuration 17 | type Config struct { 18 | UUIDs []string `json:"uuids"` 19 | Validity *time.Time `json:"validity,omitempty"` 20 | } 21 | 22 | // V1 is the main structure for the old configuration where we only supported one SSID and profile 23 | type V1 struct { 24 | UUID string `json:"uuid"` 25 | Validity *time.Time `json:"validity,omitempty"` 26 | } 27 | 28 | // Versioned contains the actual config data prefixed with a version field when marshalled as JSON 29 | type Versioned struct { 30 | // Config is the versioned configuration 31 | // It is versioned so that we can change the version and migrate older configs in the future 32 | ConfigV1 *V1 `json:"v1,omitempty"` 33 | Config *Config `json:"v2,omitempty"` 34 | } 35 | 36 | // Directory returns the directory where the config files are stored 37 | func Directory() (p string, err error) { 38 | // This follows the XDG specification at https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 39 | // From that doc: $XDG_DATA_HOME defines the base directory relative to which user-specific data files should be stored. If $XDG_DATA_HOME is either not set or empty, a default equal to $HOME/.local/share should be used. 40 | dir := os.Getenv("XDG_DATA_HOME") 41 | if dir == "" { 42 | home, err := os.UserHomeDir() 43 | if err != nil { 44 | slog.Debug("Error finding user HomeDir", "error", err) 45 | return "", err 46 | } 47 | dir = filepath.Join(home, ".local/share") 48 | } 49 | p = filepath.Join(dir, variant.DisplayName) 50 | return 51 | } 52 | 53 | // Write writes the configuration to the filesystem with the filename and string 54 | func WriteFile(filename string, content []byte) (string, error) { 55 | dir, err := Directory() 56 | if err != nil { 57 | return "", err 58 | } 59 | if err := os.MkdirAll(dir, 0o700); err != nil { 60 | return "", err 61 | } 62 | fpath := filepath.Join(dir, filename) 63 | if err := os.WriteFile(fpath, content, 0o600); err != nil { 64 | return "", err 65 | } 66 | return fpath, nil 67 | } 68 | 69 | var configName = "state" 70 | 71 | // Load loads the configuration from the state 72 | func Load() (*Config, error) { 73 | dir, err := Directory() 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | p := filepath.Join(dir, configName) 79 | 80 | b, err := os.ReadFile(p) 81 | if err != nil { 82 | slog.Debug("Error reading config file", "file", p, "error", err) 83 | return nil, err 84 | } 85 | 86 | var v Versioned 87 | 88 | err = json.Unmarshal(b, &v) 89 | if err != nil { 90 | slog.Debug("Error reading config file", "file", p, "error", err) 91 | return nil, err 92 | } 93 | utils.Verbosef("Reading config file %s", p) 94 | // If a v1 config is found, migrate it to a v2 one if that is empty 95 | hasV1 := v.ConfigV1 != nil && v.ConfigV1.UUID != "" 96 | hasV2 := v.Config != nil && len(v.Config.UUIDs) > 0 97 | if hasV1 && !hasV2 { 98 | return &Config{ 99 | UUIDs: []string{v.ConfigV1.UUID}, 100 | Validity: v.ConfigV1.Validity, 101 | }, nil 102 | } 103 | return v.Config, nil 104 | } 105 | 106 | // Write writes the configuration to the state 107 | func (c Config) Write() (err error) { 108 | // we pack the struct in a versioned struct 109 | // This is so that we can in the future migrate configs if we drastically change the format 110 | // marshal the config 111 | v := &Versioned{ 112 | Config: &c, 113 | } 114 | b, err := json.Marshal(&v) 115 | if err != nil { 116 | slog.Debug("Error writing config file", "file", configName, "error", err) 117 | return err 118 | } 119 | _, err = WriteFile(configName, b) 120 | if err != nil { 121 | slog.Debug("Error writing config file", "file", configName, "error", err) 122 | } 123 | return 124 | } 125 | -------------------------------------------------------------------------------- /internal/network/network.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/geteduroam/linux-app/internal/network/cert" 7 | "github.com/geteduroam/linux-app/internal/network/inner" 8 | "github.com/geteduroam/linux-app/internal/network/method" 9 | ) 10 | 11 | // A network belongs to the network interface when it has a method 12 | type Network interface { 13 | // Method returns the EAP method 14 | Method() method.Type 15 | // ProviderInfo returns the EAP ProviderInfo 16 | ProviderInfo() ProviderInfo 17 | } 18 | 19 | // Help is the struct that contains information on how to contact an organization 20 | type Help struct { 21 | // Email is the e-mail address as a string 22 | Email string 23 | // Phone is the phone number as a string 24 | Phone string 25 | // Web is the web URL as a string 26 | Web string 27 | } 28 | 29 | // ProviderInfo is the ProviderInfo element for the network 30 | type ProviderInfo struct { 31 | // Helpdesk contains the help information on how to contact the organization that owns the network 32 | Helpdesk Help 33 | // Name is the display name of the network as provided by the organization which should be represented in the UI 34 | Name string 35 | // Description is the description of the network as provided by the organization 36 | Description string 37 | // Logo is the logo of the network, probably the logo of the organization in base64 38 | Logo string 39 | // Terms is the terms of use for this network 40 | Terms string 41 | } 42 | 43 | // SSID is the pair of value and min RSN proto 44 | type SSID struct { 45 | // Value is the SSID 46 | Value string 47 | // MinRSN is the minimum RSN proto 48 | MinRSN string 49 | } 50 | 51 | // Base is the definition that each network always has 52 | type Base struct { 53 | // Certs is the list of CA certificates that are used 54 | Certs cert.Certs 55 | // SSIDs are the list of SSIDs 56 | SSIDs []SSID 57 | // ServerIDs is the list of server names 58 | ServerIDs []string 59 | // ProviderInfo is the ProviderInfo info 60 | ProviderInfo ProviderInfo 61 | // AnonIdentity is the anonymous identity found in the EAP config, OuterIdentity in clientcredentials 62 | // This is optional as when it's not set it will be set to the username in case of non TLS 63 | AnonIdentity string 64 | } 65 | 66 | // Credentials is the credentials belonging to the Non TLS network 67 | type Credentials struct { 68 | // Username is the string that is configured as the identity for the connection 69 | // This is gotten from the user 70 | // This username is prefixed with the InnerIdentityPrefix 71 | // It is suffixed with the InnerIdentitySuffix 72 | Username string 73 | // Prefix is the prefix for the username 74 | Prefix string 75 | // Suffix is the suffix for the username 76 | Suffix string 77 | // Password is the string that is configured as the RADIUS password for the connection 78 | Password string 79 | } 80 | 81 | // NonTLS is a structure for creating a network that has EAP method not TLS 82 | type NonTLS struct { 83 | Base 84 | // Credentials are the credentials belonging to the Non TLS network 85 | Credentials Credentials 86 | // MethodType is the method 87 | MethodType method.Type 88 | // InnerAuth is the inner authentication method 89 | InnerAuth inner.Type 90 | } 91 | 92 | func (n *NonTLS) Method() method.Type { 93 | return n.MethodType 94 | } 95 | 96 | func (n *NonTLS) ProviderInfo() ProviderInfo { 97 | return n.Base.ProviderInfo 98 | } 99 | 100 | // TLS is a structure for creating a network that has EAP method TLS 101 | type TLS struct { 102 | Base 103 | // ClientCertificate is the client certificate that is protected by a password in a PKCS12 container 104 | ClientCert *cert.ClientCert 105 | 106 | // RawPKCS12 is the raw PKCS12 container 107 | RawPKCS12 string 108 | 109 | // Password is the password that encrypts the ClientCertificate 110 | Password string 111 | } 112 | 113 | func (t *TLS) Method() method.Type { 114 | return method.TLS 115 | } 116 | 117 | func (t *TLS) ProviderInfo() ProviderInfo { 118 | return t.Base.ProviderInfo 119 | } 120 | 121 | func (t *TLS) Validity() (time.Time, time.Time) { 122 | return t.ClientCert.Validity() 123 | } 124 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "sync" 7 | 8 | "golang.org/x/exp/slog" 9 | 10 | "github.com/geteduroam/linux-app/internal/network" 11 | "github.com/jwijenbergh/puregotk/v4/adw" 12 | "github.com/jwijenbergh/puregotk/v4/gobject" 13 | "github.com/jwijenbergh/puregotk/v4/gtk" 14 | ) 15 | 16 | type LoginState interface { 17 | // Get returns the information needed 18 | // In case of credentials: username + password 19 | // In case of certificates: pkcs12 + passphrase 20 | Get() (string, string) 21 | 22 | // Initialize initializes the builder components 23 | Initialize() 24 | 25 | // Validate validates the user input 26 | // This is called before pressing the submit button 27 | Validate() error 28 | 29 | // Prefix returns the GTK builder prefix that is used to get each item 30 | Prefix() string 31 | 32 | // Destroy destroys any resources that should be called when the view is hidden 33 | Destroy() 34 | } 35 | 36 | type LoginBase struct { 37 | SignalPool 38 | builder *gtk.Builder 39 | stack *adw.ViewStack 40 | state LoginState 41 | pi network.ProviderInfo 42 | wg *sync.WaitGroup 43 | 44 | btn *gtk.Button 45 | } 46 | 47 | func (l *LoginBase) GetObject(id string, obj gobject.Ptr) { 48 | fID := l.state.Prefix() + id 49 | g := l.builder.GetObject(fID) 50 | if g == nil { 51 | panic("no such object with id: " + fID) 52 | } 53 | g.Cast(obj) 54 | } 55 | 56 | func (l *LoginBase) ShowError(err error) { 57 | slog.Error(err.Error(), "state", "login") 58 | var overlay adw.ToastOverlay 59 | l.GetObject("ToastOverlay", &overlay) 60 | defer overlay.Unref() 61 | showErrorToast(overlay, err) 62 | } 63 | 64 | func (l *LoginBase) Destroy() { 65 | l.DisconnectSignals() 66 | l.btn.Unref() 67 | } 68 | 69 | func (l *LoginBase) Get() (string, string) { 70 | defer l.state.Destroy() 71 | defer l.Destroy() 72 | l.wg.Wait() 73 | return l.state.Get() 74 | } 75 | 76 | func (l *LoginBase) Submit() { 77 | if err := l.state.Validate(); err != nil { 78 | l.ShowError(err) 79 | return 80 | } 81 | defer l.wg.Done() 82 | l.btn.SetSensitive(false) 83 | } 84 | 85 | func (l *LoginBase) fillLogo(logo *gtk.Image) error { 86 | d, err := base64.StdEncoding.DecodeString(l.pi.Logo) 87 | if err != nil { 88 | return err 89 | } 90 | pb, err := bytesPixbuf(d) 91 | if err == nil { 92 | uiThread(func() { 93 | defer logo.Unref() 94 | logo.SetFromPixbuf(pb) 95 | logo.SetSizeRequest(100, 100) 96 | }) 97 | } 98 | return nil 99 | } 100 | 101 | func (l *LoginBase) Initialize() { 102 | l.wg.Add(1) 103 | var page adw.ViewStackPage 104 | l.GetObject("Page", &page) 105 | defer page.Unref() 106 | 107 | // set the title 108 | var title gtk.Label 109 | l.GetObject("InstanceTitle", &title) 110 | defer title.Unref() 111 | styleWidget(&title, "label") 112 | title.SetText(l.pi.Name) 113 | 114 | if l.pi.Description != "" { 115 | var descr gtk.Label 116 | l.GetObject("InstanceDescription", &descr) 117 | defer descr.Unref() 118 | descr.SetText("Description: " + l.pi.Description) 119 | } 120 | 121 | // set logo 122 | var logo gtk.Image 123 | l.GetObject("InstanceLogo", &logo) 124 | defer logo.Unref() 125 | 126 | if l.pi.Logo != "" { 127 | err := l.fillLogo(&logo) 128 | // TODO: do not panic here but just log 129 | if err != nil { 130 | panic(err) 131 | } 132 | } else { 133 | logo.Hide() 134 | } 135 | // set the contact 136 | var email gtk.Label 137 | l.GetObject("InstanceEmail", &email) 138 | defer email.Unref() 139 | if l.pi.Helpdesk.Email != "" { 140 | email.SetText(fmt.Sprintf("E-mail: %s", l.pi.Helpdesk.Email)) 141 | } else { 142 | email.Hide() 143 | } 144 | var tel gtk.Label 145 | l.GetObject("InstanceTel", &tel) 146 | defer tel.Unref() 147 | if l.pi.Helpdesk.Phone != "" { 148 | tel.SetText(fmt.Sprintf("Tel.: %s", l.pi.Helpdesk.Phone)) 149 | } else { 150 | tel.Hide() 151 | } 152 | var web gtk.Label 153 | l.GetObject("InstanceWeb", &web) 154 | defer web.Unref() 155 | if l.pi.Helpdesk.Web != "" { 156 | web.SetText(fmt.Sprintf("Website: %s", l.pi.Helpdesk.Web)) 157 | } else { 158 | web.Hide() 159 | } 160 | l.state.Initialize() 161 | l.btn = >k.Button{} 162 | l.GetObject("Submit", l.btn) 163 | l.btn.SetSensitive(true) 164 | 165 | submit := func() { 166 | l.Submit() 167 | } 168 | l.AddSignal(l.btn, l.btn.ConnectSignal("clicked", &submit)) 169 | 170 | // set the page as current 171 | setPage(l.stack, &page) 172 | } 173 | -------------------------------------------------------------------------------- /internal/provider/provider_test.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/geteduroam/linux-app/internal/utils" 7 | "golang.org/x/text/language" 8 | ) 9 | 10 | func TestLocalizedStrings(t *testing.T) { 11 | cases := []struct { 12 | input LocalizedStrings 13 | lang language.Tag 14 | want string 15 | }{ 16 | { 17 | input: LocalizedStrings{ 18 | {Display: "disp_en", Lang: "en"}, 19 | {Display: "disp_nl", Lang: "nl"}, 20 | }, 21 | lang: language.English, 22 | want: "disp_en", 23 | }, 24 | { 25 | input: LocalizedStrings{ 26 | {Display: "disp_en", Lang: "en"}, 27 | {Display: "disp_nl", Lang: "nl"}, 28 | }, 29 | lang: language.Dutch, 30 | want: "disp_nl", 31 | }, 32 | { 33 | input: LocalizedStrings{ 34 | {Display: "disp_en", Lang: "en"}, 35 | }, 36 | lang: language.German, 37 | want: "disp_en", 38 | }, 39 | { 40 | input: LocalizedStrings{ 41 | {Display: "disp_en", Lang: "en"}, 42 | {Display: "disp_gb", Lang: "en_GB"}, 43 | }, 44 | lang: language.BritishEnglish, 45 | want: "disp_gb", 46 | }, 47 | { 48 | input: LocalizedStrings{ 49 | {Display: "disp_en", Lang: "en"}, 50 | {Display: "disp_gb", Lang: "en_GB"}, 51 | }, 52 | lang: language.English, 53 | want: "disp_en", 54 | }, 55 | } 56 | 57 | for _, c := range cases { 58 | systemLanguage = c.lang 59 | got := c.input.Get() 60 | if got != c.want { 61 | t.Fatalf("Got: %s, Not equal to Want: %s", got, c.want) 62 | } 63 | } 64 | } 65 | 66 | func TestFilterSort(t *testing.T) { 67 | i := Providers{ 68 | { 69 | Name: LocalizedStrings{{Display: "Provider One"}}, 70 | }, 71 | { 72 | // Diacritics 73 | Name: LocalizedStrings{{Display: "Provider Twö"}}, 74 | }, 75 | } 76 | 77 | cases := []struct { 78 | input string 79 | length int 80 | want string 81 | }{ 82 | { 83 | // Normal test 84 | input: "One", 85 | length: 1, 86 | want: "Provider One", 87 | }, 88 | { 89 | // Filter case-insensitive 90 | input: "one", 91 | length: 1, 92 | want: "Provider One", 93 | }, 94 | { 95 | // Filter case-insensitive diacriticless 96 | input: "two", 97 | length: 1, 98 | want: "Provider Twö", 99 | }, 100 | { 101 | // Filter all case-insensitive diacriticless 102 | input: "provider", 103 | length: 2, 104 | want: "Provider Twö", 105 | }, 106 | } 107 | 108 | for _, c := range cases { 109 | result := i.FilterSort(c.input) 110 | length := len(*result) 111 | name := (*result)[0].Name 112 | if name.Get() != c.want || length != c.length { 113 | t.Fatalf("Result: %s, %d, Want: %s, %d", name, length, c.want, c.length) 114 | } 115 | } 116 | } 117 | 118 | func TestFlow(t *testing.T) { 119 | p := Profile{ 120 | EapConfigEndpoint: "https://provider1.geteduroam.nl/api/eap-config/", 121 | MobileConfigEndpoint: "https://provider1.geteduroam.nl/api/eap-config/?format=mobileconfig", 122 | WebviewEndpoint: "https://provider1.geteduroam.nl/", 123 | ID: "letswifi_cat_0001", 124 | Name: LocalizedStrings{{Display: "geteduroam"}}, 125 | Type: "webview", 126 | } 127 | 128 | var flow FlowCode 129 | flow = p.Flow() 130 | if flow != RedirectFlow { 131 | t.Fatalf("Flow should be RedirectFlow") 132 | } 133 | 134 | p.Type = "" 135 | flow = p.Flow() 136 | if flow != OAuthFlow { 137 | t.Fatalf("Flow should be OAuthFlow") 138 | } 139 | 140 | p.Type = "eap-config" 141 | flow = p.Flow() 142 | if flow != DirectFlow { 143 | t.Fatalf("Flow should be DirectFlow") 144 | } 145 | } 146 | 147 | func TestRedirectURI(t *testing.T) { 148 | p := Profile{ 149 | ID: "letswifi_cat_0001", 150 | Name: LocalizedStrings{{Display: "geteduroam"}}, 151 | WebviewEndpoint: "", 152 | } 153 | 154 | cases := []struct { 155 | input string 156 | want string 157 | e string 158 | }{ 159 | { 160 | // Normal test 161 | input: "https://provider1.geteduroam.nl/", 162 | want: "https://provider1.geteduroam.nl/", 163 | e: "", 164 | }, 165 | { 166 | // No Redirect 167 | input: "", 168 | want: "", 169 | e: "no redirect found", 170 | }, 171 | { 172 | // Enforce Test 173 | input: "http://provider1.geteduroam.nl/", 174 | want: "https://provider1.geteduroam.nl/", 175 | e: "", 176 | }, 177 | { 178 | // No URL 179 | input: "foobar", 180 | want: "https://foobar", 181 | e: "", 182 | }, 183 | } 184 | 185 | for _, c := range cases { 186 | p.WebviewEndpoint = c.input 187 | r, e := p.RedirectURI() 188 | es := utils.ErrorString(e) 189 | if r != c.want || es != c.e { 190 | t.Fatalf("Result: %s, %s Want: %s, %s", r, es, c.want, c.e) 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | # goreleaser.yml 2 | 3 | version: 2 4 | builds: 5 | - main: ./cmd/geteduroam-cli 6 | id: geteduroam-cli 7 | goos: 8 | - linux 9 | goarch: 10 | - amd64 11 | - arm64 12 | binary: geteduroam-cli 13 | - main: ./cmd/geteduroam-gui 14 | id: geteduroam-gui 15 | goos: 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm64 20 | binary: geteduroam-gui 21 | - main: ./cmd/geteduroam-notifcheck 22 | id: geteduroam-notifcheck 23 | goos: 24 | - linux 25 | goarch: 26 | - amd64 27 | - arm64 28 | binary: geteduroam-notifcheck 29 | - main: ./cmd/geteduroam-cli 30 | id: getgovroam-cli 31 | tags: 32 | - getgovroam 33 | goos: 34 | - linux 35 | goarch: 36 | - amd64 37 | - arm64 38 | binary: getgovroam-cli 39 | - main: ./cmd/geteduroam-gui 40 | id: getgovroam-gui 41 | tags: 42 | - getgovroam 43 | goos: 44 | - linux 45 | goarch: 46 | - amd64 47 | - arm64 48 | binary: getgovroam-gui 49 | - main: ./cmd/geteduroam-notifcheck 50 | id: getgovroam-notifcheck 51 | tags: 52 | - getgovroam 53 | goos: 54 | - linux 55 | goarch: 56 | - amd64 57 | - arm64 58 | binary: getgovroam-notifcheck 59 | 60 | nfpms: 61 | - file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}' 62 | id: geteduroam-cli 63 | package_name: geteduroam-cli 64 | maintainer: Jeroen Wijenbergh 65 | homepage: https://geteduroam.org/ 66 | ids: 67 | - geteduroam-cli 68 | - geteduroam-notifcheck 69 | formats: 70 | - deb 71 | - rpm 72 | bindir: /usr/bin 73 | version_metadata: git 74 | release: 1 75 | description: |- 76 | Geteduroam CLI client for Linux distributions. 77 | contents: 78 | - src: systemd/user/geteduroam/ 79 | dst: /etc/systemd/user/ 80 | type: tree 81 | 82 | overrides: 83 | deb: 84 | dependencies: 85 | - network-manager 86 | rpm: 87 | dependencies: 88 | - NetworkManager 89 | 90 | - file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}' 91 | id: geteduroam-gui 92 | package_name: geteduroam-gui 93 | maintainer: Jeroen Wijenbergh 94 | homepage: https://geteduroam.org/ 95 | ids: 96 | - geteduroam-gui 97 | - geteduroam-notifcheck 98 | formats: 99 | - deb 100 | - rpm 101 | bindir: /usr/bin 102 | version_metadata: git 103 | release: 1 104 | description: |- 105 | Geteduroam GUI client for Linux distributions. 106 | contents: 107 | - src: cmd/geteduroam-gui/resources/share/ 108 | dst: /usr/share 109 | type: tree 110 | - src: systemd/user/geteduroam/ 111 | dst: /etc/systemd/user/ 112 | type: tree 113 | 114 | overrides: 115 | deb: 116 | dependencies: 117 | - network-manager 118 | - libgtk-4-1 119 | - libadwaita-1-0 120 | - libnotify-bin 121 | rpm: 122 | dependencies: 123 | - NetworkManager 124 | - gtk4 125 | - libadwaita 126 | - libnotify 127 | 128 | - file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}' 129 | id: getgovroam-cli 130 | package_name: getgovroam-cli 131 | maintainer: Jeroen Wijenbergh 132 | homepage: https://getgovroam.org/ 133 | ids: 134 | - getgovroam-cli 135 | - getgovroam-notifcheck 136 | formats: 137 | - deb 138 | - rpm 139 | bindir: /usr/bin 140 | version_metadata: git 141 | release: 1 142 | description: |- 143 | getgovroam CLI client for Linux distributions. 144 | contents: 145 | - src: systemd/user/getgovroam/ 146 | dst: /etc/systemd/user/ 147 | type: tree 148 | 149 | overrides: 150 | deb: 151 | dependencies: 152 | - network-manager 153 | rpm: 154 | dependencies: 155 | - NetworkManager 156 | 157 | - file_name_template: '{{ .PackageName }}_{{ .Os }}_{{ .Arch }}' 158 | id: getgovroam-gui 159 | package_name: getgovroam-gui 160 | maintainer: Jeroen Wijenbergh 161 | homepage: https://getgovroam.org/ 162 | ids: 163 | - getgovroam-gui 164 | - getgovroam-notifcheck 165 | formats: 166 | - deb 167 | - rpm 168 | bindir: /usr/bin 169 | version_metadata: git 170 | release: 1 171 | description: |- 172 | getgovroam GUI client for Linux distributions. 173 | contents: 174 | - src: cmd/geteduroam-gui/resources/share_getgovroam/ 175 | dst: /usr/share 176 | type: tree 177 | - src: systemd/user/getgovroam/ 178 | dst: /etc/systemd/user/ 179 | type: tree 180 | 181 | overrides: 182 | deb: 183 | dependencies: 184 | - network-manager 185 | - libgtk-4-1 186 | - libadwaita-1-0 187 | - libnotify-bin 188 | rpm: 189 | dependencies: 190 | - NetworkManager 191 | - gtk4 192 | - libadwaita 193 | - libnotify 194 | 195 | checksum: 196 | name_template: "checksums.txt" 197 | 198 | signs: 199 | - 200 | artifacts: checksum 201 | args: ["--batch", "--pinentry-mode=loopback", "--passphrase", "{{ .Env.GPG_PASSPHRASE }}", "--output", "${signature}", "--detach-sign", "${artifact}"] 202 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/list.go: -------------------------------------------------------------------------------- 1 | // this file implements abstractions over a listview 2 | package main 3 | 4 | import ( 5 | "github.com/jwijenbergh/puregotk/v4/gio" 6 | "github.com/jwijenbergh/puregotk/v4/glib" 7 | "github.com/jwijenbergh/puregotk/v4/gobject" 8 | "github.com/jwijenbergh/puregotk/v4/gtk" 9 | ) 10 | 11 | type SelectList struct { 12 | SignalPool 13 | win *gtk.ScrolledWindow 14 | list *gtk.ListView 15 | activated func(int) 16 | sorter func(a, b int) int 17 | filter func(idx int) bool 18 | store *gtk.StringList 19 | cf *gtk.CustomFilter 20 | cs *gtk.CustomSorter 21 | } 22 | 23 | func indexFromPtr(ptr uintptr) int { 24 | // TODO: Remove this once we have proper callback type signatures 25 | // The callback should already give a gobject.Binding 26 | thisl := gobject.BindingNewFromInternalPtr(ptr) 27 | return int(thisl.GetData("model-index")) 28 | } 29 | 30 | func setupList(item uintptr) { 31 | iteml := gtk.ListItemNewFromInternalPtr(item) 32 | label := gtk.NewLabel("") 33 | defer label.Unref() 34 | label.Set("xalign", 0) 35 | iteml.SetChild(&label.Widget) 36 | label.SetMarginTop(5) 37 | label.SetMarginBottom(5) 38 | } 39 | 40 | func bindList(item uintptr) { 41 | iteml := gtk.ListItemNewFromInternalPtr(item) 42 | var label gtk.Label 43 | var strobj gtk.StringObject 44 | iteml.GetChild().Cast(&label) 45 | defer label.Unref() 46 | iteml.GetItem().Cast(&strobj) 47 | defer strobj.Unref() 48 | label.SetText(strobj.GetString()) 49 | } 50 | 51 | func NewSelectList(win *gtk.ScrolledWindow, list *gtk.ListView, activated func(int), sorter func(a, b int) int) *SelectList { 52 | return &SelectList{ 53 | win: win, 54 | list: list, 55 | sorter: sorter, 56 | activated: activated, 57 | store: gtk.NewStringList(nil), 58 | } 59 | } 60 | 61 | func (s *SelectList) Destroy() { 62 | s.DisconnectSignals() 63 | s.store.Unref() 64 | } 65 | 66 | func (s *SelectList) Add(idx int, label string) { 67 | s.store.Append(label) 68 | var strobj gtk.StringObject 69 | // TODO: this is quite hacky but puregotk doesn't support subclassing yet 70 | // We have to store the mondel index as the position will not always match 1:1 71 | // In the beginning it will but after filtering the positions will only show the positions of the current model 72 | // Whereas we need the positions/index of the original list 73 | s.store.GetObject(uint(idx)).Cast(&strobj) 74 | defer strobj.Unref() 75 | strobj.SetData("model-index", uintptr(idx)) 76 | } 77 | 78 | func (s *SelectList) Remove(idx int) { 79 | s.store.Remove(uint(idx)) 80 | } 81 | 82 | func (s *SelectList) Show() { 83 | s.win.Show() 84 | } 85 | 86 | func (s *SelectList) Hide() { 87 | s.win.Hide() 88 | } 89 | 90 | func (s *SelectList) WithFiltering(filter func(idx int) bool) *SelectList { 91 | s.filter = filter 92 | return s 93 | } 94 | 95 | func (s *SelectList) Changed() { 96 | s.cs.Changed(0) 97 | if s.cf != nil { 98 | s.cf.Changed(0) 99 | } 100 | } 101 | 102 | func (s *SelectList) setupFactory() *gtk.SignalListItemFactory { 103 | factory := gtk.NewSignalListItemFactory() 104 | // TODO: Add signal for cleanup 105 | setupcb := func(_ uintptr, item uintptr) { 106 | setupList(item) 107 | } 108 | bindcb := func(_ uintptr, item uintptr) { 109 | bindList(item) 110 | } 111 | factory.Connect("signal::setup", glib.NewCallback(&setupcb), 0) 112 | 113 | // TODO: Add signal for cleanup 114 | factory.Connect("signal::bind", glib.NewCallback(&bindcb), 0) 115 | 116 | return factory 117 | } 118 | 119 | func (s *SelectList) setupSorter(base gio.ListModel) gio.ListModel { 120 | sf := (glib.CompareDataFunc)(func(this uintptr, other uintptr, _ uintptr) int { 121 | return s.sorter(indexFromPtr(this), indexFromPtr(other)) 122 | }) 123 | 124 | destroycb := (glib.DestroyNotify)(func(uintptr) { 125 | // do nothing 126 | }) 127 | 128 | s.cs = gtk.NewCustomSorter(&sf, 0, &destroycb) 129 | var sort gtk.Sorter 130 | s.cs.Cast(&sort) 131 | sm := gtk.NewSortListModel(base, &sort) 132 | return sm 133 | } 134 | 135 | func (s *SelectList) setupFilter(base gio.ListModel) gio.ListModel { 136 | cf := (gtk.CustomFilterFunc)(func(item uintptr, _ uintptr) bool { 137 | return s.filter(indexFromPtr(item)) 138 | }) 139 | destroycb := (glib.DestroyNotify)(func(uintptr) { 140 | // do nothing 141 | }) 142 | s.cf = gtk.NewCustomFilter(&cf, 0, &destroycb) 143 | var fil gtk.Filter 144 | s.cf.Cast(&fil) 145 | fl := gtk.NewFilterListModel(base, &fil) 146 | return fl 147 | } 148 | 149 | func (s *SelectList) Setup() { 150 | factory := s.setupFactory() 151 | defer factory.Unref() 152 | var model gio.ListModel = s.store 153 | if s.filter != nil { 154 | model = s.setupFilter(model) 155 | } 156 | // We never want horizontal scrollbars, but want automatically vertical ones 157 | s.win.SetPolicy(gtk.PolicyExternalValue, gtk.PolicyAutomaticValue) 158 | 159 | // further setup the list by setting the factory and model 160 | sel := gtk.NewSingleSelection(s.setupSorter(model)) 161 | defer sel.Unref() 162 | s.list.SetFactory(&factory.ListItemFactory) 163 | s.list.SetModel(sel) 164 | 165 | // We want to activate on single click always 166 | s.list.SetSingleClickActivate(true) 167 | 168 | actcb := func(_ gtk.ListView, _ uint) { 169 | var strobj gtk.StringObject 170 | sel.GetSelectedItem().Cast(&strobj) 171 | defer strobj.Unref() 172 | index := int(strobj.GetData("model-index")) 173 | s.activated(index) 174 | } 175 | 176 | // Call the activated callback 177 | s.AddSignal(s.list, s.list.ConnectActivate(&actcb)) 178 | 179 | // style the widget 180 | styleWidget(s.list, "list") 181 | } 182 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "regexp" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/geteduroam/linux-app/internal/utils" 15 | "golang.org/x/text/language" 16 | ) 17 | 18 | type LocalizedString struct { 19 | Display string `json:"display"` 20 | Lang string `json:"lang"` 21 | } 22 | 23 | type LocalizedStrings []LocalizedString 24 | 25 | func (ls LocalizedStrings) Corpus() string { 26 | var corpus strings.Builder 27 | for _, v := range ls { 28 | corpus.WriteString(v.Display) 29 | } 30 | return corpus.String() 31 | } 32 | 33 | var systemLanguage = language.English 34 | 35 | func setSystemLanguage() { 36 | lang := os.Getenv("LANG") 37 | if lang == "" { 38 | lang = os.Getenv("LC_ALL") 39 | } 40 | first := strings.Split(lang, ".")[0] 41 | tag, err := language.Parse(first) 42 | if err != nil { 43 | // TODO: log invalid language 44 | return 45 | } 46 | systemLanguage = tag 47 | } 48 | 49 | func (ls LocalizedStrings) Get() string { 50 | // first get the non-empty values 51 | var disp string 52 | var conf language.Confidence 53 | m := language.NewMatcher([]language.Tag{systemLanguage}) 54 | for _, val := range ls { 55 | // no display yet 56 | if disp == "" { 57 | disp = val.Display 58 | // we don't continue here as we still need to store the confidence 59 | } 60 | if val.Lang == "" { 61 | continue 62 | } 63 | t, err := language.Parse(val.Lang) 64 | // tag is invalid, just continue with the next option 65 | if err != nil { 66 | continue 67 | } 68 | 69 | // the confidence that this matches 70 | // is higher than the current confidence 71 | _, _, got := m.Match(t) 72 | if got > conf { 73 | disp = val.Display 74 | conf = got 75 | } 76 | } 77 | return disp 78 | } 79 | 80 | type Provider struct { 81 | ID string `json:"id"` 82 | Country string `json:"country"` 83 | Name LocalizedStrings `json:"name"` 84 | Profiles []Profile `json:"profiles"` 85 | } 86 | 87 | type Providers []Provider 88 | 89 | func SortNames(a LocalizedStrings, b LocalizedStrings, search string) int { 90 | la := strings.ToLower(a.Corpus()) 91 | lb := strings.ToLower(b.Corpus()) 92 | bd := strings.Compare(la, lb) 93 | // compute the base difference which is based on alphabetical order 94 | // if no search is defined return the base difference 95 | if search == "" { 96 | return bd 97 | } 98 | lower := strings.ToLower(search) 99 | escaped := regexp.QuoteMeta(lower) 100 | match := regexp.MustCompile(fmt.Sprintf("(^|[\\P{L}])%s[\\P{L}]", escaped)) 101 | mi := match.MatchString(la) 102 | mj := match.MatchString(lb) 103 | if mi == mj { 104 | // tiebreak on alphabetical order 105 | return bd 106 | } else if mi { 107 | return -1 108 | } 109 | return 1 110 | } 111 | 112 | type ByName struct { 113 | Providers Providers 114 | Search string 115 | } 116 | 117 | func (s ByName) Len() int { return len(s.Providers) } 118 | func (s ByName) Swap(i, j int) { s.Providers[i], s.Providers[j] = s.Providers[j], s.Providers[i] } 119 | func (s ByName) Less(i, j int) bool { 120 | diff := SortNames(s.Providers[i].Name, s.Providers[j].Name, s.Search) 121 | // if i is less than j, diff returns less than 0 122 | return diff < 0 123 | } 124 | 125 | func FilterSingle(name LocalizedStrings, search string) bool { 126 | l1, err1 := utils.RemoveDiacritics(strings.ToLower(name.Corpus())) 127 | l2, err2 := utils.RemoveDiacritics(strings.ToLower(search)) 128 | if err1 != nil || err2 != nil { 129 | return false 130 | } 131 | if !strings.Contains(l1, l2) { 132 | return false 133 | } 134 | return true 135 | } 136 | 137 | // FilterSort filters and sorts a list of providers 138 | // The sorting is done in reverse as this is used in the CLI where the most relevant providers should be shown at the bottom 139 | func (i *Providers) FilterSort(search string) *Providers { 140 | x := ByName{ 141 | Providers: Providers{}, 142 | Search: search, 143 | } 144 | for _, i := range *i { 145 | if FilterSingle(i.Name, search) { 146 | x.Providers = append(x.Providers, i) 147 | } 148 | } 149 | sort.Sort(sort.Reverse(ByName(x))) 150 | return &x.Providers 151 | } 152 | 153 | func Custom(ctx context.Context, query string) (*Provider, error) { 154 | client := http.Client{Timeout: 10 * time.Second} 155 | // parse URL and add scheme 156 | u, err := url.Parse(query) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | u.Scheme = "https" 162 | 163 | req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | req.Header.Set("Accept", "application/json") 169 | req.Header.Add("Accept", "application/eap-config") 170 | 171 | resp, err := client.Do(req) 172 | if err != nil { 173 | return nil, err 174 | } 175 | b, err := readResponse(resp) 176 | if err != nil { 177 | return nil, err 178 | } 179 | p := &Provider{ 180 | ID: "custom_provider", 181 | Name: LocalizedStrings{{Display: "Custom Provider", Lang: "en"}}, 182 | } 183 | prof := Profile{ 184 | ID: "custom_profile", 185 | Name: LocalizedStrings{{Display: "Custom Profile", Lang: "en"}}, 186 | } 187 | ct := resp.Header.Get("Content-Type") 188 | pt := "" 189 | switch ct { 190 | case "application/json": 191 | pt = "letswifi" 192 | case "application/eap-config": 193 | pt = "eap-config" 194 | default: 195 | return nil, fmt.Errorf("unknown content type: %v", ct) 196 | } 197 | prof.Type = pt 198 | prof.CachedResponse = b 199 | p.Profiles = []Profile{prof} 200 | return p, nil 201 | } 202 | 203 | func init() { 204 | setSystemLanguage() 205 | } 206 | -------------------------------------------------------------------------------- /internal/provider/profile.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os/exec" 12 | "time" 13 | 14 | "github.com/jwijenbergh/eduoauth-go" 15 | ) 16 | 17 | // Profile is the profile from discovery 18 | type Profile struct { 19 | ID string `json:"id"` 20 | EapConfigEndpoint string `json:"eapconfig_endpoint"` 21 | MobileConfigEndpoint string `json:"mobileconfig_endpoint"` 22 | LetsWifiEndpoint string `json:"letswifi_endpoint"` 23 | WebviewEndpoint string `json:"webview_endpoint"` 24 | Name LocalizedStrings `json:"name"` 25 | Type string `json:"type"` 26 | CachedResponse []byte `json:"-"` 27 | } 28 | 29 | // FlowCode is the type of flow that we will use to get the EAP config 30 | type FlowCode int8 31 | 32 | const ( 33 | // DirectFlow tells us that we can get the EAP config directly without OAuth 34 | DirectFlow FlowCode = iota 35 | // RedirectFlow tells us we need to follow the redirect 36 | RedirectFlow 37 | // OAuthFlow tells us we can get the EAP config through OAuth 38 | OAuthFlow 39 | ) 40 | 41 | // Flow gets the flow we need to go through to get the EAP config 42 | // See: https://github.com/geteduroam/cattenbak/blob/481e243f22b40e1d8d48ecac2b85705b8cb48494/cattenbak.py#L68 43 | func (p *Profile) Flow() FlowCode { 44 | switch p.Type { 45 | case "webview": 46 | return RedirectFlow 47 | case "eap-config": 48 | return DirectFlow 49 | default: 50 | return OAuthFlow 51 | } 52 | } 53 | 54 | // RedirectURI gets the redirect URI from the profile 55 | // It does some additional work by: 56 | // - Checking if the redirect URI is a URL 57 | // - Setting the scheme to HTTPS 58 | func (p *Profile) RedirectURI() (string, error) { 59 | if p.WebviewEndpoint == "" { 60 | return "", errors.New("no redirect found") 61 | } 62 | u, err := url.Parse(p.WebviewEndpoint) 63 | if err != nil { 64 | return "", err 65 | } 66 | // We enforce HTTPS 67 | if u.Scheme != "https" { 68 | u.Scheme = "https" 69 | } 70 | return u.String(), nil 71 | } 72 | 73 | // readResponse reads the HTTP response and returns the body and error 74 | // It also ensures the body is closed in the end to prevent a resource leak 75 | func readResponse(res *http.Response) ([]byte, error) { 76 | defer res.Body.Close() 77 | body, err := io.ReadAll(res.Body) 78 | if err != nil { 79 | return nil, err 80 | } 81 | if res.StatusCode < 200 || res.StatusCode > 299 { 82 | return nil, fmt.Errorf("status code is not 2xx for eap. Status code: %v, body: %v", res.StatusCode, string(body)) 83 | } 84 | return body, nil 85 | } 86 | 87 | // EAPDirect Gets an EAP config using the direct flow 88 | // It returns the byte array of the EAP config and an error if there is one 89 | func (p *Profile) EAPDirect() ([]byte, error) { 90 | if p.CachedResponse != nil { 91 | return p.CachedResponse, nil 92 | } 93 | // Do request 94 | req, err := http.NewRequest("GET", p.EapConfigEndpoint, nil) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | client := http.Client{Timeout: 10 * time.Second} 100 | res, err := client.Do(req) 101 | if err != nil { 102 | return nil, err 103 | } 104 | return readResponse(res) 105 | } 106 | 107 | type letsWifiEndpoints struct { 108 | Href string `json:"href"` 109 | API struct { 110 | AuthorizationEndpoint string `json:"authorization_endpoint"` 111 | TokenEndpoint string `json:"token_endpoint"` 112 | EapConfigEndpoint string `json:"eapconfig_endpoint"` 113 | MobileConfigEndpoint string `json:"mobileconfig_endpoint"` 114 | } `json:"http://letswifi.app/api#v2"` 115 | } 116 | 117 | func (p *Profile) getLetsWifiEndpoints() ([]byte, error) { 118 | client := http.Client{Timeout: 10 * time.Second} 119 | if p.LetsWifiEndpoint == "" { 120 | return nil, errors.New("no Let's Wifi endpoint found") 121 | } 122 | req, err := http.NewRequest("GET", p.LetsWifiEndpoint, nil) 123 | if err != nil { 124 | return nil, err 125 | } 126 | req.Header.Set("Accept", "application/json") 127 | res, err := client.Do(req) 128 | if err != nil { 129 | return nil, err 130 | } 131 | b, err := readResponse(res) 132 | if err != nil { 133 | return nil, err 134 | } 135 | return b, nil 136 | } 137 | 138 | // EAPOAuth gets the EAP metadata using OAuth 139 | func (p *Profile) EAPOAuth(ctx context.Context, auth func(authURL string)) ([]byte, error) { 140 | var err error 141 | b := p.CachedResponse 142 | if b == nil { 143 | b, err = p.getLetsWifiEndpoints() 144 | if err != nil { 145 | return nil, err 146 | } 147 | } 148 | var ep letsWifiEndpoints 149 | err = json.Unmarshal(b, &ep) 150 | if err != nil { 151 | return nil, err 152 | } 153 | 154 | o := eduoauth.OAuth{ 155 | ClientID: "app.geteduroam.sh", 156 | EndpointFunc: func(context.Context) (*eduoauth.EndpointResponse, error) { 157 | return &eduoauth.EndpointResponse{ 158 | AuthorizationURL: ep.API.AuthorizationEndpoint, 159 | TokenURL: ep.API.TokenEndpoint, 160 | }, nil 161 | }, 162 | RedirectPath: "/", 163 | } 164 | url, err := o.AuthURL(ctx, "eap-metadata") 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | // Open the authorization screen in a goroutine 170 | // TODO: make this return an error and use channels to communicate it? 171 | go auth(url) 172 | err = exec.Command("xdg-open", url).Start() 173 | if err != nil { 174 | return nil, err 175 | } 176 | err = o.Exchange(ctx, "") 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | c := o.NewHTTPClient() 182 | req, err := http.NewRequestWithContext(ctx, "POST", ep.API.EapConfigEndpoint, nil) 183 | if err != nil { 184 | return nil, err 185 | } 186 | res, err := c.Do(req) 187 | if err != nil { 188 | return nil, err 189 | } 190 | return readResponse(res) 191 | } 192 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/govroam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 14 | 15 | 16 | 22 | 23 | 24 | 33 | 39 | 42 | 43 | 44 | 46 | 52 | 58 | 63 | 64 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /internal/eap/test_data/pkcs12empty: -------------------------------------------------------------------------------- 1 | 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 76 | CRAECHZcgHD8khyhAgIIAA== 77 | -------------------------------------------------------------------------------- /internal/eap/test_data/pkcs12test: -------------------------------------------------------------------------------- 1 | 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 76 | t3AECD84W7RN9eliAgIIAA== 77 | -------------------------------------------------------------------------------- /internal/eap/test_data/eva-eap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 25 8 | 9 | 10 | MIIDtzCCAp+gAwIBAgIUCVQbKTO9PsqghECzGPqq6Fiy8REwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MB4XDTIzMDUyNDEzNTUxMFoXDTMzMDUyMTEzNTUxMFowazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyLqG9yuMhbVC5y9zofPDLeCDIUVjgPbxXHtM6uveBUtqG4PxDkTczOlYN1IsYRh2iLNRYY4cqYZ1qtW+1CZaFVowhUMbTR7Y8Ik10CrCJQqoGq1CIICBd50wTFBLU2MZU3LQTwKYb5VQgbCMvRVHWdQOYg5GSlgdJRtIbzV1d+Q7+N5jiEBsT6psSu2gBduF1ueGICKe6Fk+ckOHDpwjVGeNIxnN2hJ5ft3WReDJ7fcHLMx7lNS+ZeY35LtpYiT6I8RGlMh2bu9hMTY1jXNbEqqZ2/5TmjVygS7BEMrVage9K2I5eM8++yX27OV3Di/SM3q/RVIcu1lNKaSj0IxXhwIDAQABo1MwUTAdBgNVHQ4EFgQU0M2QAnLWEDSFdFLCm5OxvVA9D1swHwYDVR0jBBgwFoAU0M2QAnLWEDSFdFLCm5OxvVA9D1swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHHdxGNUmyZa4ER9oqSalwVy9W5y1cNr4VpxBbxJe/fBPp+xdtnYRbz1/93LwcA+bTJlvT8ez2ijOJj5QODrgeVy5r4p5/1cABnJhsszk6ffJy/n5vIqo9jp8+7ZTFGxm1QQAOoZfJM+3ft8ZFf5e8Vjh090QV2OZvV69sey+TvfAlNMVotf/CaA2zA/j4z2bmWdrLAc5VVrb1Mil4z7LHhL62oOwXrS85zuoVBQVMbh5tnYgzMnbuy0hmMDg3ClkmSQTqzPyEi0SjhqKjgLgyVa47myhxvr1y77k0rZBRzkSEMsopu+ANYoVKRpw7gmjgMmXWzvdNlbD6RgpGlR4iA== 11 | edu.nl 12 | 13 | 14 | anonymous@edu.nl 15 | edu.nl 16 | true 17 | 18 | 19 | 20 | 26 21 | 22 | 23 | 24 | 25 | 26 | 21 27 | 28 | 29 | MIIDtzCCAp+gAwIBAgIUCVQbKTO9PsqghECzGPqq6Fiy8REwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MB4XDTIzMDUyNDEzNTUxMFoXDTMzMDUyMTEzNTUxMFowazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyLqG9yuMhbVC5y9zofPDLeCDIUVjgPbxXHtM6uveBUtqG4PxDkTczOlYN1IsYRh2iLNRYY4cqYZ1qtW+1CZaFVowhUMbTR7Y8Ik10CrCJQqoGq1CIICBd50wTFBLU2MZU3LQTwKYb5VQgbCMvRVHWdQOYg5GSlgdJRtIbzV1d+Q7+N5jiEBsT6psSu2gBduF1ueGICKe6Fk+ckOHDpwjVGeNIxnN2hJ5ft3WReDJ7fcHLMx7lNS+ZeY35LtpYiT6I8RGlMh2bu9hMTY1jXNbEqqZ2/5TmjVygS7BEMrVage9K2I5eM8++yX27OV3Di/SM3q/RVIcu1lNKaSj0IxXhwIDAQABo1MwUTAdBgNVHQ4EFgQU0M2QAnLWEDSFdFLCm5OxvVA9D1swHwYDVR0jBBgwFoAU0M2QAnLWEDSFdFLCm5OxvVA9D1swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHHdxGNUmyZa4ER9oqSalwVy9W5y1cNr4VpxBbxJe/fBPp+xdtnYRbz1/93LwcA+bTJlvT8ez2ijOJj5QODrgeVy5r4p5/1cABnJhsszk6ffJy/n5vIqo9jp8+7ZTFGxm1QQAOoZfJM+3ft8ZFf5e8Vjh090QV2OZvV69sey+TvfAlNMVotf/CaA2zA/j4z2bmWdrLAc5VVrb1Mil4z7LHhL62oOwXrS85zuoVBQVMbh5tnYgzMnbuy0hmMDg3ClkmSQTqzPyEi0SjhqKjgLgyVa47myhxvr1y77k0rZBRzkSEMsopu+ANYoVKRpw7gmjgMmXWzvdNlbD6RgpGlR4iA== 30 | edu.nl 31 | 32 | 33 | anonymous@edu.nl 34 | edu.nl 35 | true 36 | 37 | 38 | 39 | 26 40 | 41 | 42 | 43 | 44 | 45 | 21 46 | 47 | 48 | MIIDtzCCAp+gAwIBAgIUCVQbKTO9PsqghECzGPqq6Fiy8REwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MB4XDTIzMDUyNDEzNTUxMFoXDTMzMDUyMTEzNTUxMFowazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyLqG9yuMhbVC5y9zofPDLeCDIUVjgPbxXHtM6uveBUtqG4PxDkTczOlYN1IsYRh2iLNRYY4cqYZ1qtW+1CZaFVowhUMbTR7Y8Ik10CrCJQqoGq1CIICBd50wTFBLU2MZU3LQTwKYb5VQgbCMvRVHWdQOYg5GSlgdJRtIbzV1d+Q7+N5jiEBsT6psSu2gBduF1ueGICKe6Fk+ckOHDpwjVGeNIxnN2hJ5ft3WReDJ7fcHLMx7lNS+ZeY35LtpYiT6I8RGlMh2bu9hMTY1jXNbEqqZ2/5TmjVygS7BEMrVage9K2I5eM8++yX27OV3Di/SM3q/RVIcu1lNKaSj0IxXhwIDAQABo1MwUTAdBgNVHQ4EFgQU0M2QAnLWEDSFdFLCm5OxvVA9D1swHwYDVR0jBBgwFoAU0M2QAnLWEDSFdFLCm5OxvVA9D1swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHHdxGNUmyZa4ER9oqSalwVy9W5y1cNr4VpxBbxJe/fBPp+xdtnYRbz1/93LwcA+bTJlvT8ez2ijOJj5QODrgeVy5r4p5/1cABnJhsszk6ffJy/n5vIqo9jp8+7ZTFGxm1QQAOoZfJM+3ft8ZFf5e8Vjh090QV2OZvV69sey+TvfAlNMVotf/CaA2zA/j4z2bmWdrLAc5VVrb1Mil4z7LHhL62oOwXrS85zuoVBQVMbh5tnYgzMnbuy0hmMDg3ClkmSQTqzPyEi0SjhqKjgLgyVa47myhxvr1y77k0rZBRzkSEMsopu+ANYoVKRpw7gmjgMmXWzvdNlbD6RgpGlR4iA== 49 | edu.nl 50 | 51 | 52 | anonymous@edu.nl 53 | edu.nl 54 | true 55 | 56 | 57 | 58 | 1 59 | 60 | 61 | 62 | 63 | 64 | 65 | eduroam 66 | CCMP 67 | 68 | 69 | 001bc50460 70 | 71 | 72 | 004096 73 | 74 | 75 | 76 | eduroam Visitor Access (eVA) 77 | eVA 78 | 79 | 5.1134653999999955 80 | 52.0890566 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/geteduroam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Group 9456 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /internal/nm/nm.go: -------------------------------------------------------------------------------- 1 | package nm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/user" 7 | "slices" 8 | "strings" 9 | 10 | "golang.org/x/exp/slog" 11 | 12 | "github.com/geteduroam/linux-app/internal/config" 13 | "github.com/geteduroam/linux-app/internal/network" 14 | "github.com/geteduroam/linux-app/internal/network/method" 15 | "github.com/geteduroam/linux-app/internal/nm/connection" 16 | "github.com/geteduroam/linux-app/internal/variant" 17 | ) 18 | 19 | // encodePath encodes a string to a path expected by NetworkManager 20 | // This path is prefixed with file:// and is explicitly NULL terminated 21 | // It returns this path as a byte array 22 | func encodePath(p string) []byte { 23 | // get the converted path 24 | // see: https://github.com/NetworkManager/NetworkManager/blob/main/examples/python/dbus/add-wifi-eap-connection.py#L12 25 | // \x00 is just NUL termination which NM expects 26 | c := fmt.Sprintf("file://%s\x00", p) 27 | return []byte(c) 28 | } 29 | 30 | // encodeFileBytes creates a file in the config directory with name `name` and contents `contents` 31 | // it ensures that the path is encoded the way NetworkManager expects it to be 32 | func encodeFileBytes(name string, contents []byte) ([]byte, error) { 33 | p, err := config.WriteFile(name, contents) 34 | if err != nil { 35 | slog.Debug("Error writing file", "error", err) 36 | return nil, err 37 | } 38 | return encodePath(p), nil 39 | } 40 | 41 | // PreviousCon gets a connection object using the previous UUID 42 | func PreviousCon(pUUID string) (*connection.Connection, error) { 43 | if pUUID == "" { 44 | return nil, errors.New("UUID is empty") 45 | } 46 | s, err := connection.NewSettings() 47 | if err != nil { 48 | slog.Debug("Error creating new settings", "error", err) 49 | return nil, err 50 | } 51 | return s.ConnectionByUUID(pUUID) 52 | } 53 | 54 | // createCon creates a new connection using the arguments 55 | // if a previous connection was found with pUUID, it updates that one instead 56 | // it returns the newly created or updated connection object 57 | func createCon(pUUID string, args connection.SettingsArgs) (*connection.Connection, error) { 58 | prev, err := PreviousCon(pUUID) 59 | // previous connection found, update it with the new settings args 60 | if err == nil { 61 | return prev, prev.Update(args) 62 | } 63 | // create a connection settings object 64 | s, err := connection.NewSettings() 65 | if err != nil { 66 | slog.Debug("Error creating new settings", "error", err) 67 | return nil, err 68 | } 69 | // create a new connection 70 | return s.AddConnection(args) 71 | } 72 | 73 | // installBase contains the code for creating a network with NetworkManager 74 | // This contains the shared network settings between TLS and NonTLS 75 | // The specific 8021x settings are given as an argument `specific` 76 | func installBaseSSID(n network.Base, ssid network.SSID, specifics map[string]interface{}, pUUID string) (string, error) { 77 | fID := fmt.Sprintf("%s (from %s)", ssid.Value, variant.DisplayName) 78 | cUser, err := user.Current() 79 | if err != nil { 80 | return "", err 81 | } 82 | sCon := map[string]interface{}{ 83 | // the priority is 1, just above the default 0 84 | // such that connections for existing eduroam profiles (and default priority) 85 | // will not be used 86 | "autoconnect-priority": 1, 87 | "permissions": []string{ 88 | fmt.Sprintf("user:%s", cUser.Username), 89 | }, 90 | "type": "802-11-wireless", 91 | "id": fID, 92 | } 93 | sWifi := map[string]interface{}{ 94 | "ssid": []byte(ssid.Value), 95 | "security": "802-11-wireless-security", 96 | } 97 | sWsec := map[string]interface{}{ 98 | "key-mgmt": "wpa-eap", 99 | "proto": []string{"rsn"}, 100 | "pairwise": []string{strings.ToLower(ssid.MinRSN)}, 101 | "group": []string{strings.ToLower(ssid.MinRSN)}, 102 | } 103 | sIP4 := map[string]interface{}{ 104 | "method": "auto", 105 | } 106 | sIP6 := map[string]interface{}{ 107 | "method": "auto", 108 | } 109 | var sids []string 110 | 111 | for _, sid := range n.ServerIDs { 112 | v := fmt.Sprintf("DNS:%s", sid) 113 | sids = append(sids, v) 114 | } 115 | caFile, err := encodeFileBytes("ca-cert.pem", n.Certs.ToPEM()) 116 | if err != nil { 117 | return "", err 118 | } 119 | s8021x := map[string]interface{}{ 120 | "ca-cert": caFile, 121 | "altsubject-matches": sids, 122 | } 123 | // add the network specific settings 124 | for k, v := range specifics { 125 | s8021x[k] = v 126 | } 127 | 128 | settings := map[string]map[string]interface{}{ 129 | "connection": sCon, 130 | "802-11-wireless": sWifi, 131 | "802-11-wireless-security": sWsec, 132 | "802-1x": s8021x, 133 | "ipv4": sIP4, 134 | "ipv6": sIP6, 135 | } 136 | con, err := createCon(pUUID, settings) 137 | if err != nil { 138 | return "", err 139 | } 140 | // get the settings from the added connection 141 | gs, err := con.GetSettings() 142 | if err != nil { 143 | return "", err 144 | } 145 | uuid, err := gs.UUID() 146 | if err != nil { 147 | return "", err 148 | } 149 | return uuid, nil 150 | } 151 | 152 | // installBase contains the code for creating a network with NetworkManager 153 | // This contains the shared network settings between TLS and NonTLS 154 | // The specific 8021x settings are given as an argument `specific` 155 | // It loops through all SSIDs and creates different networks for each 156 | func installBase(n network.Base, specifics map[string]interface{}, pUUIDs []string) ([]string, error) { 157 | // get a mapping from ssids to the accompanying uuid 158 | ssidMap := make(map[string]string) 159 | for _, puuid := range pUUIDs { 160 | con, err := PreviousCon(puuid) 161 | if err != nil { 162 | slog.Debug("failed getting previous con for UUID map", "error", err) 163 | continue 164 | } 165 | settings, err := con.GetSettings() 166 | if err != nil { 167 | slog.Debug("failed getting settings con for UUID map", "error", err) 168 | continue 169 | } 170 | ssid, err := settings.SSID() 171 | if err != nil { 172 | slog.Debug("failed getting ssid from settings con for UUID map", "error", err) 173 | continue 174 | } 175 | if ssid != "" { 176 | ssidMap[ssid] = puuid 177 | } 178 | } 179 | 180 | var added []string 181 | 182 | // remove connections no longer needed 183 | defer func() { 184 | for ssid, puuid := range ssidMap { 185 | if slices.Contains(added, puuid) { 186 | continue 187 | } 188 | slog.Debug("connection does not contain previous UUID, removing the connection", "ssid", ssid, "uuid", puuid, "added", added) 189 | con, err := PreviousCon(puuid) 190 | if err == nil { 191 | err := con.Delete() 192 | if err != nil { 193 | slog.Debug("failed to delete connection", "error", err) 194 | } 195 | } else { 196 | slog.Debug("previous connection does not exist, not removing", "error", err) 197 | } 198 | } 199 | }() 200 | 201 | // add new connections 202 | for _, ssid := range n.SSIDs { 203 | uuid := ssidMap[ssid.Value] 204 | guuid, err := installBaseSSID(n, ssid, specifics, uuid) 205 | if err != nil { 206 | return added, err 207 | } 208 | added = append(added, guuid) 209 | } 210 | 211 | return added, nil 212 | } 213 | 214 | // Install installs a non TLS network and returns an error if it cannot configure it 215 | // Right now it adds a new profile that is not automatically added 216 | // It returns the uuid if the connection was added successfully 217 | func Install(n network.NonTLS, pUUIDs []string) ([]string, error) { 218 | s8021x := map[string]interface{}{ 219 | "eap": []string{ 220 | n.Method().String(), 221 | }, 222 | "anonymous-identity": n.AnonIdentity, 223 | "identity": n.Credentials.Username, 224 | "password": n.Credentials.Password, 225 | "password-flags": 0, 226 | } 227 | if n.InnerAuth.EAP() && n.MethodType == method.TTLS { 228 | s8021x["phase2-autheap"] = n.InnerAuth.String() 229 | } else { 230 | s8021x["phase2-auth"] = n.InnerAuth.String() 231 | } 232 | return installBase(n.Base, s8021x, pUUIDs) 233 | } 234 | 235 | // InstallTLS installs a TLS network and returns an error if it cannot configure it 236 | // Right now it adds a new profile that is not automatically added 237 | // It returns the uuid if the connection was added successfully 238 | func InstallTLS(n network.TLS, pUUIDs []string) ([]string, error) { 239 | ccFile, err := encodeFileBytes("client-cert.pem", n.ClientCert.ToPEM()) 240 | if err != nil { 241 | return nil, err 242 | } 243 | pkp, pwd, err := n.ClientCert.PrivateKeyPEMEnc() 244 | if err != nil { 245 | return nil, err 246 | } 247 | pkFile, err := encodeFileBytes("private-key.pem", pkp) 248 | if err != nil { 249 | return nil, err 250 | } 251 | s8021x := map[string]interface{}{ 252 | "eap": []string{ 253 | "tls", 254 | }, 255 | "identity": n.AnonIdentity, 256 | "client-cert": ccFile, 257 | "private-key": pkFile, 258 | "private-key-password": pwd, 259 | "private-key-password-flags": 0, 260 | } 261 | return installBase(n.Base, s8021x, pUUIDs) 262 | } 263 | -------------------------------------------------------------------------------- /internal/eap/eap_test.go: -------------------------------------------------------------------------------- 1 | package eap 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/geteduroam/linux-app/internal/network" 10 | "github.com/geteduroam/linux-app/internal/network/cert" 11 | "github.com/geteduroam/linux-app/internal/network/inner" 12 | "github.com/geteduroam/linux-app/internal/network/method" 13 | "github.com/geteduroam/linux-app/internal/utils" 14 | ) 15 | 16 | type authMethodTest struct { 17 | want inner.Type 18 | err string 19 | } 20 | 21 | func testAuthMethod(t *testing.T, eip *EAPIdentityProvider, cases []authMethodTest) { 22 | methods, err := eip.authenticationMethods() 23 | if err != nil { 24 | t.Fatalf("failed getting authentication methods: %v", err) 25 | } 26 | 27 | for i, c := range cases { 28 | m := methods.AuthenticationMethod[i] 29 | r, err := m.preferredInnerAuthType() 30 | if r != c.want { 31 | t.Fatalf("method is not what is expected, got: %d, want: %d", r, c.want) 32 | } 33 | if utils.ErrorString(err) != c.err { 34 | t.Fatalf("error is not expected, got: %v, want: %v", err, c.err) 35 | } 36 | } 37 | } 38 | 39 | func testProviderInfo(t *testing.T, eip *EAPIdentityProvider, pi network.ProviderInfo) { 40 | got := eip.PInfo() 41 | if !reflect.DeepEqual(pi, got) { 42 | t.Fatalf("provider info not equal, want: %v, got: %v", pi, got) 43 | } 44 | } 45 | 46 | type ssidSettingsTest struct { 47 | SSIDs []network.SSID 48 | err string 49 | } 50 | 51 | func testSSIDSettings(t *testing.T, eip *EAPIdentityProvider, settings ssidSettingsTest) { 52 | gotSSIDs, gotErr := eip.SSIDSettings() 53 | if !reflect.DeepEqual(gotSSIDs, settings.SSIDs) { 54 | t.Fatalf("SSIDs is not equal, got: %v, want: %v", gotSSIDs, settings.SSIDs) 55 | } 56 | gotErrS := utils.ErrorString(gotErr) 57 | if gotErrS != settings.err { 58 | t.Fatalf("Error for SSID settings is not equal, got: %v, want: %v", gotErrS, settings.err) 59 | } 60 | } 61 | 62 | type networkTest struct { 63 | n network.Network 64 | err string 65 | } 66 | 67 | type parseTest struct { 68 | filename string 69 | authMethodTests []authMethodTest 70 | providerInfoTest network.ProviderInfo 71 | ssidTest ssidSettingsTest 72 | netTest networkTest 73 | } 74 | 75 | func mustParseCert(t *testing.T, root string) cert.Certs { 76 | c, err := cert.New([]string{root}) 77 | if err != nil { 78 | t.Fatalf("failed to generate certs: %v", err) 79 | } 80 | return *c 81 | } 82 | 83 | func TestParse(t *testing.T) { 84 | tests := []parseTest{ 85 | { 86 | filename: "eva-eap.xml", 87 | authMethodTests: []authMethodTest{ 88 | // In this file we expect everything to be valid so errors are nil 89 | // The first authentication method, PEAP, only has EapMschapv2 (26) as inner defined 90 | { 91 | want: inner.EapMschapv2, 92 | err: "", 93 | }, 94 | // The second authentication method, 21 (TTLS), only has 26 again, EapMschapv2 95 | { 96 | want: inner.EapMschapv2, 97 | err: "", 98 | }, 99 | // The third authentication method, 21 TTLS, only has a Non EAP Auth method 1 100 | { 101 | want: inner.Pap, 102 | err: "", 103 | }, 104 | }, 105 | providerInfoTest: network.ProviderInfo{ 106 | Name: "eduroam Visitor Access (eVA)", 107 | Description: "eVA", 108 | }, 109 | ssidTest: ssidSettingsTest{ 110 | SSIDs: []network.SSID{{ 111 | Value: "eduroam", 112 | MinRSN: "CCMP", 113 | }}, 114 | err: "", 115 | }, 116 | netTest: networkTest{ 117 | n: &network.NonTLS{ 118 | Base: network.Base{ 119 | AnonIdentity: "anonymous@edu.nl", 120 | Certs: mustParseCert(t, 121 | "MIIDtzCCAp+gAwIBAgIUCVQbKTO9PsqghECzGPqq6Fiy8REwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MB4XDTIzMDUyNDEzNTUxMFoXDTMzMDUyMTEzNTUxMFowazELMAkGA1UEBhMCTkwxEzARBgNVBAgMClNvbWUtU3RhdGUxEjAQBgNVBAcMCUFtc3RlcmRhbTEQMA4GA1UECgwHVGVzdGluZzENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJVGVzdCB0ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyLqG9yuMhbVC5y9zofPDLeCDIUVjgPbxXHtM6uveBUtqG4PxDkTczOlYN1IsYRh2iLNRYY4cqYZ1qtW+1CZaFVowhUMbTR7Y8Ik10CrCJQqoGq1CIICBd50wTFBLU2MZU3LQTwKYb5VQgbCMvRVHWdQOYg5GSlgdJRtIbzV1d+Q7+N5jiEBsT6psSu2gBduF1ueGICKe6Fk+ckOHDpwjVGeNIxnN2hJ5ft3WReDJ7fcHLMx7lNS+ZeY35LtpYiT6I8RGlMh2bu9hMTY1jXNbEqqZ2/5TmjVygS7BEMrVage9K2I5eM8++yX27OV3Di/SM3q/RVIcu1lNKaSj0IxXhwIDAQABo1MwUTAdBgNVHQ4EFgQU0M2QAnLWEDSFdFLCm5OxvVA9D1swHwYDVR0jBBgwFoAU0M2QAnLWEDSFdFLCm5OxvVA9D1swDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHHdxGNUmyZa4ER9oqSalwVy9W5y1cNr4VpxBbxJe/fBPp+xdtnYRbz1/93LwcA+bTJlvT8ez2ijOJj5QODrgeVy5r4p5/1cABnJhsszk6ffJy/n5vIqo9jp8+7ZTFGxm1QQAOoZfJM+3ft8ZFf5e8Vjh090QV2OZvV69sey+TvfAlNMVotf/CaA2zA/j4z2bmWdrLAc5VVrb1Mil4z7LHhL62oOwXrS85zuoVBQVMbh5tnYgzMnbuy0hmMDg3ClkmSQTqzPyEi0SjhqKjgLgyVa47myhxvr1y77k0rZBRzkSEMsopu+ANYoVKRpw7gmjgMmXWzvdNlbD6RgpGlR4iA=="), 122 | SSIDs: []network.SSID{{ 123 | Value: "eduroam", 124 | MinRSN: "CCMP", 125 | }}, 126 | ServerIDs: []string{ 127 | "edu.nl", 128 | }, 129 | ProviderInfo: network.ProviderInfo{ 130 | Name: "eduroam Visitor Access (eVA)", 131 | Description: "eVA", 132 | }, 133 | }, 134 | Credentials: network.Credentials{ 135 | Suffix: "@edu.nl", 136 | }, 137 | MethodType: method.PEAP, 138 | InnerAuth: inner.EapMschapv2, 139 | }, 140 | err: "", 141 | }, 142 | }, 143 | { 144 | // changes: 145 | // - removed provider info 146 | // - changed auth methods to contain some invalid values 147 | // - ssid entry removed 148 | filename: "eva-eap-changed.xml", 149 | authMethodTests: []authMethodTest{ 150 | // The first authentication method, PEAP, has no inners defined 151 | { 152 | want: inner.None, 153 | err: "the authentication method has no inner authentication methods", 154 | }, 155 | // The second authentication method, also PEAP, has changed inner type to Non EAP 156 | { 157 | want: inner.None, 158 | err: "no viable inner authentication method found", 159 | }, 160 | // The third authentication method, PEAP, has changed inner type to Non EAP 161 | { 162 | want: inner.Mschap, 163 | err: "", 164 | }, 165 | }, 166 | providerInfoTest: network.ProviderInfo{}, 167 | ssidTest: ssidSettingsTest{ 168 | err: "no viable SSID entries found", 169 | }, 170 | netTest: networkTest{ 171 | n: nil, 172 | err: "no viable SSID entries found", 173 | }, 174 | }, 175 | } 176 | 177 | for _, c := range tests { 178 | b, err := os.ReadFile(path.Join("test_data", c.filename)) 179 | if err != nil { 180 | t.Fatalf("failed reading file: %v", err) 181 | } 182 | 183 | eipl, err := Parse(b) 184 | if err != nil { 185 | t.Fatalf("failed parsing file: %v", err) 186 | } 187 | eip := eipl.EAPIdentityProvider 188 | if eip == nil { 189 | t.Fatalf("no eap identity provider found") 190 | } 191 | 192 | // test the individual components that make up the network 193 | testAuthMethod(t, eip, c.authMethodTests) 194 | testProviderInfo(t, eip, c.providerInfoTest) 195 | testSSIDSettings(t, eip, c.ssidTest) 196 | 197 | // finally test the whole network we get back 198 | n, err := eipl.Network() 199 | errS := utils.ErrorString(err) 200 | if errS != c.netTest.err { 201 | t.Fatalf("network error not equal. Got: %v, want: %v", errS, c.netTest.err) 202 | } 203 | if !reflect.DeepEqual(n, c.netTest.n) { 204 | t.Fatalf("networks are not equal. Got: %v, want: %v", n, c.netTest.n) 205 | } 206 | } 207 | } 208 | 209 | func TestCertFromContainer(t *testing.T) { 210 | cases := []struct { 211 | // valid data will be generated with test_data/genpkcs12.sh 212 | cert string 213 | passphrase string 214 | hasErr bool 215 | wantNil bool 216 | }{ 217 | { 218 | // a valid PKCS12 encrypted with "" should have no error 219 | cert: "pkcs12empty", 220 | passphrase: "", 221 | hasErr: false, 222 | wantNil: false, 223 | }, 224 | { 225 | // a valid PKCS12 encrypted with "" should have an error if we pass passphrase test 226 | cert: "pkcs12empty", 227 | passphrase: "test", 228 | hasErr: true, 229 | wantNil: true, 230 | }, 231 | { 232 | // a valid PKCS12 encrypted with "test" should have no error if we give passphrase "" 233 | // This is because in this case we want to try again. It should however give nil data 234 | cert: "pkcs12test", 235 | passphrase: "", 236 | hasErr: false, 237 | wantNil: true, 238 | }, 239 | { 240 | // a valid PKCS12 encrypted with "test" should have no error if we give passphrase "test" 241 | cert: "pkcs12test", 242 | passphrase: "test", 243 | hasErr: false, 244 | wantNil: false, 245 | }, 246 | { 247 | // a valid PKCS12 encrypted with "test" should have an error if we give passphrase "test2" 248 | cert: "pkcs12test", 249 | passphrase: "test2", 250 | hasErr: true, 251 | wantNil: true, 252 | }, 253 | // an invalid PKCS12 should always have an error 254 | { 255 | cert: "pkcs12invalid", 256 | passphrase: "", 257 | hasErr: true, 258 | wantNil: true, 259 | }, 260 | { 261 | cert: "pkcs12invalid", 262 | passphrase: "test", 263 | hasErr: true, 264 | wantNil: true, 265 | }, 266 | } 267 | 268 | for idx, c := range cases { 269 | cert, err := os.ReadFile(path.Join("test_data", c.cert)) 270 | if err != nil { 271 | t.Fatalf("Failed reading cert file: %v, idx: %v", err, idx) 272 | } 273 | g, gerr := certFromContainer(string(cert), c.passphrase) 274 | if c.hasErr != (gerr != nil) { 275 | t.Fatalf("Has error: %v, got error: %v, idx: %v", c.hasErr, gerr, idx) 276 | } 277 | // test if nil is always returned if we have an error 278 | if c.wantNil != (g == nil) { 279 | t.Fatalf("Want nil: %v, got result: %v, idx: %v", c.wantNil, g, idx) 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/images/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Stroke 11394 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/main.go: -------------------------------------------------------------------------------- 1 | //go:debug x509negativeserial=1 2 | package main 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "golang.org/x/exp/slog" 16 | 17 | "github.com/jwijenbergh/puregotk/v4/adw" 18 | "github.com/jwijenbergh/puregotk/v4/gdk" 19 | "github.com/jwijenbergh/puregotk/v4/gio" 20 | "github.com/jwijenbergh/puregotk/v4/glib" 21 | "github.com/jwijenbergh/puregotk/v4/gtk" 22 | 23 | "github.com/geteduroam/linux-app/internal/discovery" 24 | "github.com/geteduroam/linux-app/internal/handler" 25 | "github.com/geteduroam/linux-app/internal/log" 26 | "github.com/geteduroam/linux-app/internal/network" 27 | "github.com/geteduroam/linux-app/internal/provider" 28 | "github.com/geteduroam/linux-app/internal/variant" 29 | "github.com/geteduroam/linux-app/internal/version" 30 | ) 31 | 32 | type serverList struct { 33 | sync.Mutex 34 | store *gtk.StringList 35 | providers provider.Providers 36 | list *SelectList 37 | custom bool 38 | } 39 | 40 | func (s *serverList) get(idx int, query string) (*provider.Provider, error) { 41 | if s.custom && idx == len(s.providers) { 42 | // TODO: add context 43 | return provider.Custom(context.Background(), query) 44 | } 45 | if idx < 0 || idx > len(s.providers) { 46 | return nil, errors.New("index out of range") 47 | } 48 | return &s.providers[idx], nil 49 | } 50 | 51 | func (s *serverList) getNames(idx int, query string) (*provider.LocalizedStrings, error) { 52 | if s.custom && idx == len(s.providers) { 53 | return &provider.LocalizedStrings{{Display: query}}, nil 54 | } 55 | if idx < 0 || idx > len(s.providers) { 56 | return nil, errors.New("index out of range") 57 | } 58 | return &s.providers[idx].Name, nil 59 | } 60 | 61 | func (s *serverList) Fill() { 62 | s.Lock() 63 | defer s.Unlock() 64 | for idx, inst := range s.providers { 65 | s.list.Add(idx, inst.Name.Get()) 66 | } 67 | } 68 | 69 | func (s *serverList) AddCustom(label string) { 70 | if s.custom { 71 | s.RemoveCustom() 72 | } 73 | s.list.Add(len(s.providers), label) 74 | s.custom = true 75 | } 76 | 77 | func (s *serverList) RemoveCustom() { 78 | if !s.custom { 79 | return 80 | } 81 | s.list.Remove(len(s.providers)) 82 | s.custom = false 83 | } 84 | 85 | type mainState struct { 86 | app *adw.Application 87 | builder *gtk.Builder 88 | servers *serverList 89 | scroll *gtk.ScrolledWindow 90 | stack *adw.ViewStack 91 | } 92 | 93 | func (m *mainState) initServers() { 94 | m.servers = &serverList{} 95 | m.servers.store = gtk.NewStringList(nil) 96 | } 97 | 98 | func (m *mainState) activate() { 99 | var page adw.ViewStackPage 100 | m.builder.GetObject("searchPage").Cast(&page) 101 | defer page.Unref() 102 | // we do not use setPage here as the margin is already set in the clamp 103 | // This is so that the background is set on the current main page only but it expands to each side fully 104 | m.stack.SetVisibleChild(page.GetChild()) 105 | } 106 | 107 | func (m *mainState) askCredentials(c network.Credentials, pi network.ProviderInfo) (string, string, error) { 108 | login := NewCredentialsStateBase(m.builder, m.stack, c, pi) 109 | login.Initialize() 110 | user, pass := login.Get() 111 | return user, pass, nil 112 | } 113 | 114 | func (m *mainState) askCertificate(cert string, pwd string, pi network.ProviderInfo) (string, string, error) { 115 | base := NewCertificateStateBase(m.app.GetActiveWindow(), m.builder, m.stack, cert, pwd, pi) 116 | base.Initialize() 117 | cert, pass := base.Get() 118 | return cert, pass, nil 119 | } 120 | 121 | func (m *mainState) file(metadata []byte) (*time.Time, *time.Time, error) { 122 | h := handler.Handlers{ 123 | CredentialsH: m.askCredentials, 124 | CertificateH: m.askCertificate, 125 | } 126 | return h.Configure(metadata) 127 | } 128 | 129 | func (m *mainState) direct(p provider.Profile) error { 130 | config, err := p.EAPDirect() 131 | if err != nil { 132 | return err 133 | } 134 | _, _, err = m.file(config) 135 | return err 136 | } 137 | 138 | func (m *mainState) local(path string) (*time.Time, *time.Time, error) { 139 | b, err := os.ReadFile(path) 140 | if err != nil { 141 | return nil, nil, err 142 | } 143 | vBeg, vEnd, err := m.file(b) 144 | if err != nil { 145 | return nil, nil, err 146 | } 147 | return vBeg, vEnd, nil 148 | } 149 | 150 | func (m *mainState) oauth(ctx context.Context, p provider.Profile) (*time.Time, *time.Time, error) { 151 | ctx, cancel := context.WithCancel(ctx) 152 | defer cancel() 153 | config, err := p.EAPOAuth(ctx, func(url string) { 154 | uiThread(func() { 155 | l := NewLoadingPage(m.builder, m.stack, "Your browser has been opened to authorize the client", func() { 156 | cancel() 157 | }) 158 | l.Initialize() 159 | // If the browser does not open for some reason the user could grab it with stdout 160 | // We could also show it in the UI but this might mean too much clutter 161 | fmt.Println("Browser has been opened with URL:", url) 162 | }) 163 | }) 164 | if err != nil { 165 | return nil, nil, err 166 | } 167 | 168 | return m.file(config) 169 | } 170 | 171 | func (m *mainState) rowActivated(sel provider.Provider) { 172 | var page gtk.Box 173 | m.builder.GetObject("searchPage").Cast(&page) 174 | defer page.Unref() 175 | l := NewLoadingPage(m.builder, m.stack, "Loading organization details...", nil) 176 | l.Initialize() 177 | ctx := context.Background() 178 | chosen := func(p provider.Profile) (err error) { 179 | defer func() { 180 | err = ensureContextError(ctx, err) 181 | }() 182 | var vBeg *time.Time 183 | var vEnd *time.Time 184 | var isredirect bool 185 | switch p.Flow() { 186 | case provider.DirectFlow: 187 | err = m.direct(p) 188 | if err != nil { 189 | return err 190 | } 191 | case provider.OAuthFlow: 192 | vBeg, vEnd, err = m.oauth(ctx, p) 193 | if err != nil { 194 | return err 195 | } 196 | case provider.RedirectFlow: 197 | isredirect = true 198 | url, err := p.RedirectURI() 199 | if err != nil { 200 | return err 201 | } 202 | err = exec.Command("xdg-open", url).Start() 203 | if err != nil { 204 | return err 205 | } 206 | fmt.Println("Browser has been opened with URL:", url) 207 | } 208 | s := NewSuccessState(m.builder, m.app.GetActiveWindow(), m.stack, vBeg, vEnd, isredirect) 209 | uiThread(func() { 210 | s.Initialize() 211 | }) 212 | return nil 213 | } 214 | cb := func(p provider.Profile) { 215 | err := chosen(p) 216 | if err != nil { 217 | l.Hide() 218 | m.activate() 219 | m.ShowError(err) 220 | } 221 | } 222 | if len(sel.Profiles) > 1 { 223 | profiles := NewProfileState(m.builder, m.stack, sel.Profiles, cb) 224 | profiles.Initialize() 225 | } else { 226 | go cb(sel.Profiles[0]) 227 | } 228 | } 229 | 230 | func (m *mainState) initList() { 231 | // style the treeview 232 | var list gtk.ListView 233 | m.builder.GetObject("searchList").Cast(&list) 234 | defer list.Unref() 235 | 236 | cache := discovery.NewCache() 237 | inst, err := cache.Providers() 238 | if err != nil { 239 | m.ShowError(err) 240 | return 241 | } 242 | m.servers.providers = *inst 243 | 244 | var search gtk.SearchEntry 245 | m.builder.GetObject("searchBox").Cast(&search) 246 | defer search.Unref() 247 | 248 | activated := func(idx int) { 249 | l := NewLoadingPage(m.builder, m.stack, "Loading server details...", nil) 250 | l.Initialize() 251 | cb := func(inst *provider.Provider, err error) { 252 | if err != nil { 253 | l.Hide() 254 | m.activate() 255 | m.ShowError(err) 256 | return 257 | } 258 | m.rowActivated(*inst) 259 | } 260 | go func() { 261 | inst, err := m.servers.get(idx, search.GetText()) 262 | uiThread(func() { 263 | cb(inst, err) 264 | }) 265 | }() 266 | } 267 | 268 | sorter := func(a, b int) int { 269 | query := search.GetText() 270 | n1, err := m.servers.getNames(a, query) 271 | if err != nil { 272 | return -1 273 | } 274 | n2, err := m.servers.getNames(b, query) 275 | if err != nil { 276 | return -1 277 | } 278 | return provider.SortNames(*n1, *n2, query) 279 | } 280 | 281 | m.servers.list = NewSelectList(m.scroll, &list, activated, sorter).WithFiltering(func(idx int) bool { 282 | query := search.GetText() 283 | n, err := m.servers.getNames(idx, query) 284 | if err != nil { 285 | return false 286 | } 287 | return provider.FilterSingle(*n, query) 288 | }) 289 | 290 | // Fill the servers in the select list 291 | m.servers.Fill() 292 | 293 | // Further set up the list 294 | m.servers.list.Setup() 295 | 296 | changedcb := func(_ gtk.SearchEntry) { 297 | // TODO len returns length in bytes 298 | // utf8.RuneCountInString() counts number of characters (runes) 299 | query := search.GetText() 300 | if len(query) <= 2 { 301 | m.servers.list.Hide() 302 | return 303 | } 304 | // url entered 305 | if strings.Count(query, ".") >= 2 { 306 | m.servers.AddCustom(search.GetText()) 307 | } else { 308 | m.servers.RemoveCustom() 309 | } 310 | m.servers.list.Changed() 311 | m.servers.list.Show() 312 | } 313 | 314 | // Update the list when searching 315 | search.ConnectSearchChanged(&changedcb) 316 | } 317 | 318 | func (m *mainState) localMetadata() { 319 | fd, err := NewFileDialog(m.app.GetActiveWindow(), "Choose an EAP metadata file") 320 | if err != nil { 321 | m.ShowError(err) 322 | return 323 | } 324 | fd.Run(func(path string) { 325 | go func() { 326 | vBeg, vEnd, err := m.local(path) 327 | if err != nil { 328 | uiThread(func() { 329 | m.activate() 330 | m.ShowError(err) 331 | }) 332 | return 333 | } 334 | s := NewSuccessState(m.builder, m.app.GetActiveWindow(), m.stack, vBeg, vEnd, false) 335 | s.Initialize() 336 | }() 337 | }) 338 | } 339 | 340 | func (m *mainState) initBurger() { 341 | var gears gtk.MenuButton 342 | m.builder.GetObject("gears").Cast(&gears) 343 | defer gears.Unref() 344 | 345 | var menu gio.MenuModel 346 | builder := gtk.NewBuilderFromString(MustResource("gears.ui"), -1) 347 | defer builder.Unref() 348 | builder.GetObject("menu").Cast(&menu) 349 | gears.SetMenuModel(&menu) 350 | 351 | imp := gio.NewSimpleAction("import-local", nil) 352 | actcb := func(_ gio.SimpleAction, _ uintptr) { 353 | m.localMetadata() 354 | } 355 | imp.ConnectActivate(&actcb) 356 | 357 | aboutcb := func(_ gio.SimpleAction, _ uintptr) { 358 | awin := gtk.NewAboutDialog() 359 | awin.SetName(fmt.Sprintf("%s Linux client", variant.DisplayName)) 360 | pb, err := bytesPixbuf([]byte(MustResource("images/heart.png"))) 361 | if err == nil { 362 | texture := gdk.NewTextureForPixbuf(pb) 363 | defer pb.Unref() 364 | awin.SetLogo(texture) 365 | defer texture.Unref() 366 | } 367 | lpath, err := log.Location(fmt.Sprintf("%s-gui", variant.DisplayName)) 368 | if err == nil { 369 | awin.SetSystemInformation("Log location: " + lpath) 370 | } 371 | awin.SetProgramName(fmt.Sprintf("%s GUI", variant.DisplayName)) 372 | awin.SetComments(fmt.Sprintf("Client to easily and securely configure %s", variant.ProfileName)) 373 | awin.SetAuthors([]string{"Jeroen Wijenbergh", "Martin van Es", "Alexandru Cacean"}) 374 | awin.SetVersion(version.Get()) 375 | awin.SetWebsite("https://github.com/geteduroam/linux-app") 376 | // SetLicenseType has a scary warning: "comes with absolutely no warranty" 377 | // While it is true according to the license, I find it unfriendly 378 | awin.SetLicense("This application has a BSD 3 license.") 379 | awin.SetTransientFor(m.app.GetActiveWindow()) 380 | awin.Show() 381 | } 382 | 383 | about := gio.NewSimpleAction("about", nil) 384 | about.ConnectActivate(&aboutcb) 385 | 386 | m.app.AddAction(imp) 387 | m.app.AddAction(about) 388 | } 389 | 390 | func (m *mainState) Initialize() { 391 | m.scroll = >k.ScrolledWindow{} 392 | m.builder.GetObject("searchScroll").Cast(m.scroll) 393 | m.stack = &adw.ViewStack{} 394 | m.builder.GetObject("pageStack").Cast(m.stack) 395 | m.initServers() 396 | m.initList() 397 | m.initBurger() 398 | m.activate() 399 | } 400 | 401 | func (m *mainState) ShowError(err error) { 402 | if errors.Is(err, context.Canceled) { 403 | return 404 | } 405 | slog.Error(err.Error(), "state", "main") 406 | var overlay adw.ToastOverlay 407 | m.builder.GetObject("searchToastOverlay").Cast(&overlay) 408 | defer overlay.Unref() 409 | showErrorToast(overlay, err) 410 | } 411 | 412 | type ui struct { 413 | builder *gtk.Builder 414 | app *adw.Application 415 | } 416 | 417 | func (ui *ui) initBuilder() { 418 | // open the builder 419 | ui.builder = gtk.NewBuilderFromString(MustResource("main.ui"), -1) 420 | } 421 | 422 | func (ui *ui) initWindow() { 423 | // get the window 424 | var win adw.Window 425 | ui.builder.GetObject("mainWindow").Cast(&win) 426 | defer win.Unref() 427 | win.SetTitle(fmt.Sprintf("%s GUI", variant.DisplayName)) 428 | win.SetDefaultSize(400, 600) 429 | // style the window using the css 430 | var search adw.ViewStackPage 431 | ui.builder.GetObject("searchPage").Cast(&search) 432 | defer search.Unref() 433 | widg := search.GetChild() 434 | defer widg.Unref() 435 | styleWidget(widg, fmt.Sprintf("window_%s", variant.DisplayName)) 436 | ui.app.AddWindow(&win.Window) 437 | win.Show() 438 | } 439 | 440 | func (ui *ui) activate() { 441 | // Initialize the builder 442 | // The builder essentially just creates the bulk of the UI by loading the XML specification 443 | ui.initBuilder() 444 | 445 | // Initialize the rest of the window 446 | ui.initWindow() 447 | 448 | // Go to the main state 449 | m := &mainState{app: ui.app, builder: ui.builder} 450 | m.Initialize() 451 | } 452 | 453 | func (ui *ui) Run(args []string) int { 454 | ui.app = adw.NewApplication(variant.AppID, gio.GApplicationFlagsNoneValue) 455 | defer ui.app.Unref() 456 | actcb := func(_ gio.Application) { 457 | ui.activate() 458 | } 459 | ui.app.ConnectActivate(&actcb) 460 | 461 | return ui.app.Run(len(args), args) 462 | } 463 | 464 | func main() { 465 | const usage = `Usage of %s: 466 | -h, --help Prints this help information 467 | --version Prints version information 468 | -d, --debug Debug 469 | --gtk-args Arguments to pass to gtk as a string, e.g. "--help". These flags are split on spaces 470 | 471 | This GUI binary is used to add an eduroam connection profile with integration using NetworkManager and Gtk. 472 | 473 | Log file location: %s 474 | ` 475 | 476 | var help bool 477 | var versionf bool 478 | var debug bool 479 | var gtkarg string 480 | program := fmt.Sprintf("%s-gui", variant.DisplayName) 481 | lpath, err := log.Location(program) 482 | if err != nil { 483 | lpath = "N/A" 484 | } 485 | flag.BoolVar(&help, "help", false, "Show help") 486 | flag.BoolVar(&help, "h", false, "Show help") 487 | flag.BoolVar(&versionf, "version", false, "Show version") 488 | flag.BoolVar(&debug, "d", false, "Debug") 489 | flag.BoolVar(&debug, "debug", false, "Debug") 490 | flag.StringVar(>karg, "gtk-args", "", "Gtk arguments") 491 | flag.Usage = func() { fmt.Printf(usage, program, lpath) } 492 | flag.Parse() 493 | if help { 494 | flag.Usage() 495 | return 496 | } 497 | 498 | if versionf { 499 | fmt.Println(version.Get()) 500 | return 501 | } 502 | 503 | var handler glib.LogFunc = func(pkg string, level glib.LogLevelFlags, msg string, _ uintptr) { 504 | switch level { 505 | case glib.GLogLevelErrorValue: 506 | slog.Error(msg, "pkg-name", pkg, "level", level) 507 | case glib.GLogLevelCriticalValue: 508 | // Ignore some false positives due to Gtk bug 509 | // Happens when pressing "Import Metadata" 510 | // see https://discourse.gnome.org/t/menu-button-gives-error-messages-with-latest-gtk4/15689/3 511 | ignore := "_gtk_css_corner_value_get_%s: assertion 'corner->class == >K_CSS_VALUE_CORNER' failed" 512 | if fmt.Sprintf(ignore, "x") == msg || fmt.Sprintf(ignore, "y") == msg { 513 | return 514 | } 515 | slog.Error("pkg-name", pkg, "level", level) 516 | case glib.GLogLevelWarningValue: 517 | slog.Warn(msg, "pkg-name", pkg, "level", level) 518 | case glib.GLogLevelMessageValue: 519 | slog.Info(msg, "pkg-name", pkg, "level", level) 520 | case glib.GLogLevelInfoValue: 521 | slog.Info(msg, "pkg-name", pkg, "level", level) 522 | case glib.GLogLevelDebugValue: 523 | slog.Debug(msg, "pkg-name", pkg, "level", level) 524 | case glib.GLogLevelMaskValue: 525 | slog.Debug(msg, "pkg-name", pkg, "level", level) 526 | } 527 | } 528 | 529 | glib.LogSetDefaultHandler(&handler, 0) 530 | 531 | log.Initialize(program, debug) 532 | ui := ui{} 533 | args := []string{os.Args[0]} 534 | if gtkarg != "" { 535 | args = append(args, strings.Split(gtkarg, " ")...) 536 | } 537 | ui.Run(args) 538 | } 539 | -------------------------------------------------------------------------------- /cmd/geteduroam-gui/resources/main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | vertical 9 | 10 | 11 | 12 | 13 | 14 | True 15 | 16 | 17 | 18 | 19 | 20 | 21 | 5 22 | 10 23 | 10 24 | 5 25 | 26 | 27 | vertical 28 | 29 | 30 | 31 | 32 | 33 | horizontal 34 | 5 35 | 36 | 37 | True 38 | Search for your organization (e.g. SURF) 39 | 40 | 41 | 42 | 43 | none 44 | 45 | 46 | 47 | 48 | 49 | 50 | 0.9 51 | 52 | 53 | 54 | True 55 | True 56 | False 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | center 72 | vertical 73 | center 74 | 75 | 76 | 77 | 78 | 79 | 5 80 | 81 | 82 | 83 | 84 | 5 85 | Cancel 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | center 99 | vertical 100 | center 101 | 10 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | center 172 | vertical 173 | center 174 | 10 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | center 260 | vertical 261 | center 262 | 25 263 | 264 | 265 | Success! 266 | 267 | 268 | 269 | 270 | Your eduroam profile has been added 271 | 272 | 273 | 274 | 275 | Your profile is valid for: 276 | 277 | 278 | 279 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | center 292 | vertical 293 | center 294 | 25 295 | 296 | 297 | Please select a profile: 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | True 306 | True 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | --------------------------------------------------------------------------------