diff --git a/README.md b/README.md index d9fbf72..9996ae2 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,11 @@ sudo dpkg -i ./wireguird_amd64.deb ## Compile -deb dependencies: `wireguard-tools libgtk-3-dev libayatana-appindicator3-dev golang-go resolvconf` +deb dependencies: `sudo apt install wireguard-tools libgtk-3-dev libayatana-appindicator3-dev golang-go resolvconf` ```sh git clone https://github.com/UnnoTed/wireguird cd wireguird -chmod +x ./*.sh -./deps.sh -./package.sh -./install.sh +go install github.com/goreleaser/goreleaser/v2@latest +goreleaser release --snapshot --clean ``` diff --git a/deb/DEBIAN/changelog b/deb/DEBIAN/changelog index 69f355a..f9419a4 100644 --- a/deb/DEBIAN/changelog +++ b/deb/DEBIAN/changelog @@ -1,3 +1,12 @@ +wireguird (1.2) UNRELEASED; urgency=medium + + * Add new setting to show country flags on tunnel names + * Change distribution packaging + * Update checks run once per run + * Add new setting to auto-reconnect on start + + -- unknown Sat, 26 Apr 2026 15:50:00 +0100 + wireguird (1.1) UNRELEASED; urgency=medium * Add ability to import multiple config files at once (zip archive) diff --git a/deb/DEBIAN/control b/deb/DEBIAN/control index f3e50d6..54091d8 100644 --- a/deb/DEBIAN/control +++ b/deb/DEBIAN/control @@ -1,6 +1,6 @@ Package: wireguird Source: wireguird -Version: 1.1 +Version: 1.2 Section: utils Priority: extra Architecture: amd64 diff --git a/fileb0x.toml b/fileb0x.toml old mode 100755 new mode 100644 index 2395d11..618253a --- a/fileb0x.toml +++ b/fileb0x.toml @@ -116,7 +116,7 @@ debug = false # type: array of objects [[custom]] # type: array of strings - files = ["*.glade"] + files = ["*.glade", "./Icon/", "./flags/"] # base is the path that will be removed from all files' path # type: string diff --git a/gui/countries/countries.go b/gui/countries/countries.go new file mode 100644 index 0000000..64a0bd8 --- /dev/null +++ b/gui/countries/countries.go @@ -0,0 +1,133 @@ +package countries + +import ( + "fmt" + "io" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/oschwald/maxminddb-golang" + "github.com/rs/zerolog/log" +) + +const ( + mmdbURL = "https://github.com/iplocate/ip-address-databases/raw/refs/heads/main/ip-to-country/ip-to-country.mmdb" + mmdbFile = "./countries.mmdb" +) + +var ( + initErr error + once sync.Once // OpenDatabase runs once + db *maxminddb.Reader + + ready = make(chan struct{}) + dnsCache = map[string]string{} + dnsCacheMu sync.Mutex +) + +type IPRecord struct { + CountryCode string `maxminddb:"country_code"` + Country struct { + IsoCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` +} + +func OpenDatabase() error { + once.Do(func() { + defer close(ready) + log.Debug().Msg("initializing country database") + + // downloads the db when doesnt exist or is older than a week + if s, err := os.Stat(mmdbFile); os.IsNotExist(err) || s.ModTime().Before(time.Now().Add(-7*24*time.Hour)) { + if err := downloadDatabase(); err != nil { + initErr = fmt.Errorf("failed to download country database: %w", err) + return + } + } + + var err error + db, err = maxminddb.Open(mmdbFile) + if err != nil { + initErr = fmt.Errorf("failed to open country database: %w", err) + return + } + + log.Debug().Msg("country database opened successfully") + }) + + return initErr +} + +func downloadDatabase() error { + resp, err := http.Get(mmdbURL) + if err != nil { + return fmt.Errorf("failed to download mmdb: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + out, err := os.Create(mmdbFile) + if err != nil { + return fmt.Errorf("failed to create local file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed to write data to disk: %w", err) + } + + return nil +} + +func Find(ipStr string) (string, error) { + <-ready + + if initErr != nil { + return "", fmt.Errorf("database initialization failed: %w", initErr) + } + + if val, ok := dnsCache[ipStr]; ok { + log.Debug().Str("ip", ipStr).Str("country", val).Msg("country found in cache") + return val, nil + } + + ip := net.ParseIP(ipStr) + if ip == nil { + return "", fmt.Errorf("invalid IP address format: %s", ipStr) + } + + var record IPRecord + err := db.Lookup(ip, &record) + if err != nil { + return "", fmt.Errorf("error during database lookup: %w", err) + } + + if record.CountryCode != "" { + dnsCacheMu.Lock() + dnsCache[ipStr] = record.CountryCode + dnsCacheMu.Unlock() + return record.CountryCode, nil + } + + if record.Country.IsoCode != "" { + dnsCacheMu.Lock() + dnsCache[ipStr] = record.Country.IsoCode + dnsCacheMu.Unlock() + return record.Country.IsoCode, nil + } + + return "Unknown", nil +} + +func CloseDatabase() { + if db != nil { + db.Close() + } +} diff --git a/gui/endpoints/endpoints.go b/gui/endpoints/endpoints.go new file mode 100644 index 0000000..0114b0d --- /dev/null +++ b/gui/endpoints/endpoints.go @@ -0,0 +1,63 @@ +package endpoints + +import ( + "net" + "strings" + "unicode" +) + +const ( + Unknown = "Unknown" + IPV4 = "IPV4" + IPV6 = "IPV6" + FQDN = "FQDN" +) + +func Categorize(endpoint string) string { + // no empty or urls + if endpoint == "" || strings.Contains(endpoint, "://") { + return Unknown + } + + host, _, err := net.SplitHostPort(endpoint) + if err != nil { + // when no port is present, use the it as the host. + host = endpoint + } + + // IPV6 with brackets + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + inner := host[1 : len(host)-1] + + ip := net.ParseIP(inner) + if ip != nil && ip.To4() == nil { + return IPV6 + } + + // invalid [IPV6] + return Unknown + } + + if ip := net.ParseIP(host); ip != nil { + if ip.To4() != nil { + return IPV4 + } + + return IPV6 + } + + // FQDNs have no web url chars like ?, #, @... + if host == "" || strings.ContainsAny(host, " /?#@:[]") { + return Unknown + } + + // FQDNs must be printable and contain no spaces + // printable means that strange chars like chinese should work + for _, r := range host { + if !unicode.IsPrint(r) || unicode.IsSpace(r) { + return Unknown + } + } + + return FQDN +} diff --git a/gui/endpoints/endpoints_test.go b/gui/endpoints/endpoints_test.go new file mode 100644 index 0000000..b2b719e --- /dev/null +++ b/gui/endpoints/endpoints_test.go @@ -0,0 +1,41 @@ +package endpoints + +import ( + "testing" +) + +func TestCategorization(t *testing.T) { + tests := []struct { + name string + endpoint string + want string + }{ + {"Empty string", "", Unknown}, + {"Just a colon", ":", Unknown}, + {"Empty brackets", "[]", Unknown}, + {"Empty brackets with port", "[]:51820", Unknown}, + + {"IPV4 with port", "192.168.1.1:51820", IPV4}, + {"IPV4 without port", "10.0.0.1", IPV4}, + + {"IPV6 with brackets & port", "[2001:db8::1]:51820", IPV6}, + {"IPV6 with brackets, no port", "[2001:db8::1]", IPV6}, + {"IPV6 no brackets, no port", "2001:db8::1", IPV6}, + + {"Valid FQDN with port", "vpn.example.com:51820", FQDN}, + {"Valid FQDN without port", "wg.my-domain.net", FQDN}, + + {"FQDN with spaces", "my vpn.com:51820", Unknown}, + {"URL with scheme", "https://vpn.example.com", Unknown}, + {"Malformed IP in brackets", "[192.168.1.1]", Unknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Categorize(tt.endpoint) + if got != tt.want { + t.Errorf("\nCategorize(%q)\n got: %s\n want: %s", tt.endpoint, got, tt.want) + } + }) + } +} diff --git a/gui/get/generator/generator.go b/gui/get/generator/generator.go index b57c7d4..6a43932 100644 --- a/gui/get/generator/generator.go +++ b/gui/get/generator/generator.go @@ -27,6 +27,7 @@ func main() { "Switch", "Window", "Entry", + "Image", "Label", "Stack", "Box", diff --git a/gui/get/gtk.go b/gui/get/gtk.go index 315fe3a..6918d10 100644 --- a/gui/get/gtk.go +++ b/gui/get/gtk.go @@ -246,6 +246,20 @@ func Entry(name string) (*gtk.Entry, error) { return entry1, nil } +func Image(name string) (*gtk.Image, error) { + obj, err := Builder.GetObject(name) + if err != nil { + return nil, err + } + + image1, ok := obj.(*gtk.Image) + if !ok { + return nil, errors.New("cant get *gtk.Image: " + name) + } + + return image1, nil +} + func Label(name string) (*gtk.Label, error) { obj, err := Builder.GetObject(name) if err != nil { diff --git a/gui/gui.go b/gui/gui.go old mode 100755 new mode 100644 index c2b17dd..a5da065 --- a/gui/gui.go +++ b/gui/gui.go @@ -7,7 +7,10 @@ import ( "time" "github.com/UnnoTed/go-appindicator" + "github.com/UnnoTed/wireguird/gui/countries" "github.com/UnnoTed/wireguird/gui/get" + "github.com/UnnoTed/wireguird/static" + "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/rs/zerolog/log" @@ -15,14 +18,15 @@ import ( ) const ( - Version = "1.1.0" + Version = "1.2.0" Repo = "https://github.com/UnnoTed/wireguird" TunnelsPath = "/etc/wireguard/" - IconPath = "/opt/wireguird/Icon/" + IconPath = "./Icon/" ) var ( settingsWindow *gtk.Window + updateChecked bool editorWindow *gtk.Window application *gtk.Application indicator *appindicator.Indicator @@ -99,7 +103,27 @@ func Create(app *gtk.Application, b *gtk.Builder, w *gtk.ApplicationWindow, ind }() } + if Settings.CountryFlags { + go func() { + if err := countries.OpenDatabase(); err != nil { + log.Error().Err(err).Msg("error getting country database") + } + }() + } + window.HideOnDelete() + + icons := map[string]string{ + "icon_delete": "Icon/delete.png", + "icon_tunnel": "Icon/add_tunnel.png", + "icon_zip": "Icon/zip.png", + } + + for name, path := range icons { + if err := loadGlobalEmbeddedImage(name, path); err != nil { + return err + } + } return nil } @@ -167,6 +191,12 @@ func createSettings(show bool) (*gtk.Window, error) { func updateCheck() error { log.Info().Msg("Checking for updates") + + // once per run + if updateChecked { + return nil + } + resp, err := http.Get("https://api.github.com/repos/UnnoTed/wireguird/releases") if err != nil { return err @@ -197,5 +227,58 @@ func updateCheck() error { } } + updateChecked = true + return nil +} + +func loadEmbeddedImage(path string) (*gtk.Image, error) { + data, err := static.ReadFile(path) + if err != nil { + return nil, err + } + + loader, err := gdk.PixbufLoaderNew() + if err != nil { + return nil, err + } + + loader.Write(data) + loader.Close() + + pixbuf, err := loader.GetPixbuf() + if err != nil { + return nil, err + } + + log.Debug().Str("path", path).Msg("loaded embedded image") + return gtk.ImageNewFromPixbuf(pixbuf) +} + +func loadGlobalEmbeddedImage(id string, path string) error { + data, err := static.ReadFile(path) + if err != nil { + return err + } + + loader, err := gdk.PixbufLoaderNew() + if err != nil { + return err + } + + loader.Write(data) + loader.Close() + + pixbuf, err := loader.GetPixbuf() + if err != nil { + return err + } + + img, err := get.Image(id) + if err != nil { + return err + } + + img.SetFromPixbuf(pixbuf) + log.Debug().Str("path", path).Msg("loaded global embedded image") return nil } diff --git a/gui/tunnels.go b/gui/tunnels.go old mode 100755 new mode 100644 index 8906335..e033db9 --- a/gui/tunnels.go +++ b/gui/tunnels.go @@ -2,9 +2,12 @@ package gui import ( "archive/zip" + "bytes" "errors" "fmt" "io" + "io/ioutil" + "net" "os" "os/exec" "path/filepath" @@ -17,6 +20,8 @@ import ( "github.com/ungerik/go-dry" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/UnnoTed/wireguird/gui/countries" + "github.com/UnnoTed/wireguird/gui/endpoints" "github.com/UnnoTed/wireguird/gui/get" "github.com/UnnoTed/wireguird/settings" "github.com/dustin/go-humanize" @@ -56,16 +61,20 @@ type Tunnels struct { } Settings struct { - MultipleTunnels *gtk.CheckButton - StartOnTray *gtk.CheckButton - CheckUpdates *gtk.CheckButton + MultipleTunnels *gtk.CheckButton + StartOnTray *gtk.CheckButton + CheckUpdates *gtk.CheckButton + CountryFlags *gtk.CheckButton + RememberConnectedServers *gtk.CheckButton + Passwordless *gtk.CheckButton } ButtonChangeState *gtk.Button icons map[string]*gtk.Image ticker *time.Ticker - lastSelected string + lastSelected string + reconnectedRememberedServers bool grayIcon *gtk.Image greenIcon *gtk.Image @@ -76,12 +85,12 @@ func (t *Tunnels) Create() error { t.ticker = time.NewTicker(1 * time.Second) var err error - t.grayIcon, err = gtk.ImageNewFromFile(IconPath + "not_connected.png") + t.grayIcon, err = loadEmbeddedImage(IconPath + "not_connected.png") if err != nil { return err } - t.greenIcon, err = gtk.ImageNewFromFile(IconPath + "connected.png") + t.greenIcon, err = loadEmbeddedImage(IconPath + "connected.png") if err != nil { return err } @@ -207,122 +216,7 @@ func (t *Tunnels) Create() error { } t.ButtonChangeState.Connect("clicked", func() { - err := func() error { - list, err := wgc.Devices() - if err != nil { - return err - } - - activeNames := t.ActiveDeviceName() - row := tl.GetSelectedRow() - // row not found for config - if row == nil { - return nil - } - - // conf name - name, err := row.GetName() - if err != nil { - return err - } - - // https://github.com/UnnoTed/wireguird/issues/11#issuecomment-1332047191 - if len(name) >= 16 { - ShowError(window, errors.New("Tunnel's file name is too long ("+strconv.Itoa(len(name))+"), max length: 15")) - } - - // disconnect from given tunnel - dc := func(d *wgtypes.Device) error { - glib.IdleAdd(func() { - t.icons[d.Name].SetFromPixbuf(t.grayIcon.GetPixbuf()) - }) - - c := exec.Command("wg-quick", "down", d.Name) - output, err := c.Output() - if err != nil { - es := string(err.(*exec.ExitError).Stderr) - log.Error().Err(err).Str("output", string(output)).Str("error", es).Msg("wg-quick down error") - - oerr := err.Error() + "\nwg-quick's output:\n" + es - wlog("ERROR", oerr) - return errors.New(oerr) - } - - indicator.SetIcon("wireguard_off") - return wlog("INFO", "Disconnected from "+d.Name) - } - - // disconnects from all tunnels before connecting to a new one - // when the multipleTunnels option is disabled - if Settings.MultipleTunnels { - log.Info().Str("name", name).Msg("NAME") - d, err := wgc.Device(name) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - if !errors.Is(err, os.ErrNotExist) { - if err := dc(d); err != nil { - return err - } - } - - } else { - for _, d := range list { - if err := dc(d); err != nil { - return err - } - } - } - - // dont connect to the new one as this is a disconnect action - if dry.StringListContains(activeNames, name) { - t.UpdateRow(row) - - glib.IdleAdd(func() { - if len(activeNames) == 1 { - header.SetSubtitle("Not connected!") - } else { - activeNames := t.ActiveDeviceName() - header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) - } - }) - return nil - } - - // connect to a tunnel - c := exec.Command("wg-quick", "up", name) - output, err := c.Output() - if err != nil { - es := string(err.(*exec.ExitError).Stderr) - log.Error().Err(err).Str("output", string(output)).Str("error", es).Msg("wg-quick up error") - - oerr := err.Error() + "\nwg-quick's output:\n" + es - wlog("ERROR", oerr) - return errors.New(oerr) - } - - // update header label with tunnel names - glib.IdleAdd(func() { - activeNames := t.ActiveDeviceName() - header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) - }) - - // set icon to connected for the tunnel's row - glib.IdleAdd(func() { - t.icons[name].SetFromPixbuf(t.greenIcon.GetPixbuf()) - t.UpdateRow(row) - indicator.SetIcon("wg_connected") - }) - - if err := wlog("INFO", "Connected to "+name); err != nil { - return err - } - - return nil - }() - - if err != nil { + if err = t.Connect(tl.GetSelectedRow(), ""); err != nil { ShowError(window, err) } }) @@ -793,6 +687,8 @@ func (t *Tunnels) Create() error { } btnSettingsSave.Connect("clicked", func() { + prevCountryFlags := Settings.CountryFlags + err := func() error { t.ToSettings() Settings.Save() @@ -804,6 +700,24 @@ func (t *Tunnels) Create() error { if err != nil { ShowError(window, err, "settings save error") } + + // only download country database when previously disabled and now enabled + if !prevCountryFlags && Settings.CountryFlags { + go func() { + err := countries.OpenDatabase() + if err != nil { + ShowError(window, err, "download country database error") + } + }() + } + + if err := t.SetPasswordless(); err != nil { + ShowError(window, err, "set passwordless error") + } + + if err := t.ScanTunnels(); err != nil { + ShowError(window, err, "scan tunnels error") + } }) // button: settings cancel @@ -908,6 +822,18 @@ func (t *Tunnels) Create() error { } }() + go func() { + if Settings.RememberConnectedServers && Settings.ConnectedServers != nil && len(Settings.ConnectedServers) > 0 { + for tunnel := range Settings.ConnectedServers { + if err := t.Connect(nil, tunnel); err != nil { + log.Error().Err(err).Str("tunnel", tunnel).Msg("error connecting to tunnel") + wlog("ERROR", "error connecting to tunnel") + } + } + } + + t.reconnectedRememberedServers = true + }() return nil } @@ -915,6 +841,9 @@ func (t *Tunnels) ToSettings() { Settings.MultipleTunnels = t.Settings.MultipleTunnels.GetActive() Settings.StartOnTray = t.Settings.StartOnTray.GetActive() Settings.CheckUpdates = t.Settings.CheckUpdates.GetActive() + Settings.CountryFlags = t.Settings.CountryFlags.GetActive() + Settings.RememberConnectedServers = t.Settings.RememberConnectedServers.GetActive() + Settings.Passwordless = t.Settings.Passwordless.GetActive() } func (t *Tunnels) FromSettings() error { @@ -941,6 +870,27 @@ func (t *Tunnels) FromSettings() error { } t.Settings.CheckUpdates.SetActive(Settings.CheckUpdates) + // checkbox: country flags + t.Settings.CountryFlags, err = get.CheckButton("settings_country_flags") + if err != nil { + return err + } + t.Settings.CountryFlags.SetActive(Settings.CountryFlags) + + // checkbox: remember connected servers + t.Settings.RememberConnectedServers, err = get.CheckButton("settings_remember_connected") + if err != nil { + return err + } + t.Settings.RememberConnectedServers.SetActive(Settings.RememberConnectedServers) + + // checkbox: passwordless + t.Settings.Passwordless, err = get.CheckButton("settings_polkit_policy") + if err != nil { + return err + } + t.Settings.Passwordless.SetActive(Settings.Passwordless) + return nil } @@ -1028,13 +978,17 @@ func (t *Tunnels) ScanTunnels() error { // showError(err) return err } + configByName := map[string]string{} for _, fileName := range list { if !strings.HasSuffix(fileName, ".conf") { continue } - configList = append(configList, strings.TrimSuffix(fileName, ".conf")) + name := strings.TrimSuffix(fileName, ".conf") + configByName[name] = TunnelsPath + fileName + + configList = append(configList, name) } tl, err := get.ListBox("tunnel_list") @@ -1056,7 +1010,11 @@ func (t *Tunnels) ScanTunnels() error { }) activeNames := t.ActiveDeviceName() - header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) + if len(activeNames) > 0 { + header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) + } else { + header.SetSubtitle("No active tunnels") + } lasti := len(configList) - 1 for i, name := range configList { @@ -1097,6 +1055,82 @@ func (t *Tunnels) ScanTunnels() error { img.SetVExpand(false) img.SetHExpand(false) + var country *gtk.Image + if Settings.CountryFlags { + cfg, err := ini.Load(configByName[name]) + if err != nil { + return err + } + + peersec := cfg.Section("Peer") + + nothing, err := loadEmbeddedImage("./flags/IDK.png") + if err != nil { + return err + } + country = nothing + country.SetVAlign(gtk.ALIGN_CENTER) + country.SetHAlign(gtk.ALIGN_START) + country.SetSizeRequest(10, 10) + country.SetVExpand(false) + country.SetHExpand(false) + + go func() { + err := func() error { + endpoint := strings.Split(peersec.Key("Endpoint").String(), ":")[0] + log.Debug().Str("endpoint", endpoint).Msg("checking country flag for tunnel") + + ip := "" + switch endpoints.Categorize(endpoint) { + case endpoints.IPV4, endpoints.IPV6: + ip = endpoint + case endpoints.FQDN: + ips, err := net.LookupIP(endpoint) + if err != nil { + log.Error().Err(err).Str("endpoint", endpoint).Msg("dns lookup error") + return err + } + + if len(ips) > 0 { + ip = ips[0].String() + } else { + return errors.New("endpoint \"" + endpoint + "\" coudln't find ip from dns") + } + default: + return errors.New("endpoint \"" + endpoint + "\" is invalid") + } + + log.Debug().Str("endpoint", endpoint).Str("ip", ip).Msg("found ip for tunnel endpoint") + + countryCode, err := countries.Find(ip) + if err != nil { + log.Error().Err(err).Msg("find country error") + return err + } + + log.Debug().Str("country", countryCode).Str("ip", ip).Msg("found country for tunnel") + + flag, err := loadEmbeddedImage("./flags/" + countryCode + ".png") + if err != nil { + log.Error().Err(err).Str("country", countryCode).Str("ip", ip).Msg("error loading embedded image") + return err + } + + // prevents crash when deleting servers + glib.IdleAdd(func() { + if country != nil && country.GetPixbuf() != nil { + country.SetFromPixbuf(flag.GetPixbuf()) + } + }) + return nil + }() + if err != nil { + log.Error().Err(err).Msg("country flag error") + wlog("ERROR", "country flag error: "+err.Error()) + } + }() + } + label, err := gtk.LabelNew(name) if err != nil { return err @@ -1114,6 +1148,11 @@ func (t *Tunnels) ScanTunnels() error { } box.Add(img) + + if Settings.CountryFlags { + box.Add(country) + } + box.Add(label) row.SetName(name) @@ -1171,6 +1210,170 @@ func (t *Tunnels) ActiveDeviceName() []string { return names } +func (t *Tunnels) Connect(row *gtk.ListBoxRow, name string) error { + list, err := wgc.Devices() + if err != nil { + return err + } + + activeNames := t.ActiveDeviceName() + if row != nil && name == "" { + // conf name + name, err = row.GetName() + if err != nil { + return err + } + } + + // https://github.com/UnnoTed/wireguird/issues/11#issuecomment-1332047191 + if len(name) >= 16 { + ShowError(window, errors.New("Tunnel's file name is too long ("+strconv.Itoa(len(name))+"), max length: 15")) + } + + // disconnect from given tunnel + dc := func(d *wgtypes.Device) error { + glib.IdleAdd(func() { + t.icons[d.Name].SetFromPixbuf(t.grayIcon.GetPixbuf()) + }) + + c := exec.Command("wg-quick", "down", d.Name) + output, err := c.Output() + if err != nil { + es := string(err.(*exec.ExitError).Stderr) + log.Error().Err(err).Str("output", string(output)).Str("error", es).Msg("wg-quick down error") + + oerr := err.Error() + "\nwg-quick's output:\n" + es + wlog("ERROR", oerr) + return errors.New(oerr) + } + + if Settings.RememberConnectedServers && Settings.ConnectedServers != nil && len(Settings.ConnectedServers) > 0 && t.reconnectedRememberedServers { + delete(Settings.ConnectedServers, d.Name) + if err := Settings.Save(); err != nil { + log.Error().Err(err).Msg("Error saving settings on disconnect") + wlog("INFO", "Error saving settings on disconnect: "+err.Error()) + } + } + + indicator.SetIcon("wireguard_off") + return wlog("INFO", "Disconnected from "+d.Name) + } + + // disconnects from all tunnels before connecting to a new one + // when the multipleTunnels option is disabled + if Settings.MultipleTunnels { + log.Info().Str("name", name).Msg("NAME") + d, err := wgc.Device(name) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + if !errors.Is(err, os.ErrNotExist) { + if err := dc(d); err != nil { + return err + } + } + + } else { + for _, d := range list { + if err := dc(d); err != nil { + return err + } + } + } + + // dont connect to the new one as this is a disconnect action + if dry.StringListContains(activeNames, name) { + t.UpdateRow(row) + + glib.IdleAdd(func() { + if len(activeNames) == 1 { + header.SetSubtitle("Not connected!") + } else { + activeNames := t.ActiveDeviceName() + header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) + } + }) + return nil + } + + // connect to a tunnel + c := exec.Command("wg-quick", "up", name) + output, err := c.Output() + if err != nil { + es := string(err.(*exec.ExitError).Stderr) + log.Error().Err(err).Str("output", string(output)).Str("error", es).Msg("wg-quick up error") + + oerr := err.Error() + "\nwg-quick's output:\n" + es + wlog("ERROR", oerr) + return errors.New(oerr) + } + + if Settings.RememberConnectedServers { + // using time because order may be important + if Settings.ConnectedServers == nil { + Settings.ConnectedServers = map[string]int64{} + } + + Settings.ConnectedServers[name] = time.Now().Unix() + if err := Settings.Save(); err != nil { + log.Error().Err(err).Msg("Error saving settings on connect") + } + } + + // update header label with tunnel names + glib.IdleAdd(func() { + activeNames := t.ActiveDeviceName() + header.SetSubtitle("Connected to " + strings.Join(activeNames, ", ")) + }) + + // set icon to connected for the tunnel's row + glib.IdleAdd(func() { + t.icons[name].SetFromPixbuf(t.greenIcon.GetPixbuf()) + t.UpdateRow(row) + indicator.SetIcon("wg_connected") + }) + + if err := wlog("INFO", "Connected to "+name); err != nil { + return err + } + + return nil +} + +func (t *Tunnels) SetPasswordless() error { + // not installed + if !dry.FileExists("/usr/share/polkit-1/actions/wireguird.policy") { + return nil + } + + content, err := ioutil.ReadFile("/usr/share/polkit-1/actions/wireguird.policy") + if err != nil { + return err + } + + changed := false + if Settings.Passwordless && bytes.Contains(content, []byte("auth_admin")) { + content = bytes.Replace(content, []byte("auth_admin"), []byte("yes"), 1) + content = bytes.Replace(content, []byte("auth_admin"), []byte("yes"), 1) + content = bytes.Replace(content, []byte("auth_admin_keep"), []byte("yes"), 1) + changed = true + } else if !Settings.Passwordless && bytes.Contains(content, []byte("yes")) { + content = bytes.Replace(content, []byte("yes"), []byte("auth_admin"), 1) + content = bytes.Replace(content, []byte("yes"), []byte("auth_admin"), 1) + content = bytes.Replace(content, []byte("yes"), []byte("auth_admin_keep"), 1) + changed = true + } + + if changed { + if err := ioutil.WriteFile("/usr/share/polkit-1/actions/wireguird.policy", content, 0644); err != nil { + return err + } + } + + return nil +} + func wlog(t string, text string) error { wlogs, err := get.ListBox("wireguard_logs") if err != nil { diff --git a/main.go b/main.go old mode 100755 new mode 100644 index eefa82f..b3856b8 --- a/main.go +++ b/main.go @@ -3,11 +3,14 @@ package main import ( + "fmt" "os" + "path/filepath" "github.com/UnnoTed/go-appindicator" "github.com/UnnoTed/horizontal" "github.com/UnnoTed/wireguird/gui" + "github.com/UnnoTed/wireguird/gui/countries" "github.com/UnnoTed/wireguird/static" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" @@ -15,9 +18,31 @@ import ( "github.com/rs/zerolog/log" ) +var iconTmpDir = os.TempDir() var win *gtk.ApplicationWindow func main() { + if os.Geteuid() != 0 { + fmt.Println("\n\n\n\n") + fmt.Println("===========================================================================") + fmt.Println("===========================================================================") + fmt.Println("===========================================================================") + fmt.Println("=== ===") + fmt.Println("=== ===") + fmt.Println("=== Wireguird must be ran as root to access /etc/wireguard and wg-quick ===") + fmt.Println("=== Use pkexec to run as root: ===") + fmt.Println("=== pkexec wireguird ===") + fmt.Println("=== ===") + fmt.Println("=== ===") + fmt.Println("===========================================================================") + fmt.Println("===========================================================================") + fmt.Println("===========================================================================") + return + } + + defer os.Remove(iconTmpDir) + defer countries.CloseDatabase() + log.Logger = log.Output(horizontal.ConsoleWriter{Out: os.Stderr}) log.Info().Uint("major", gtk.GetMajorVersion()).Uint("minor", gtk.GetMinorVersion()).Uint("micro", gtk.GetMicroVersion()).Msg("GTK Version") @@ -57,8 +82,23 @@ func createTray(application *gtk.Application) (*appindicator.Indicator, error) { return nil, err } + icons := []string{"wireguard_off", "wg_connected"} + for _, icon := range icons { + iconBytes, err := static.ReadFile("./Icon/" + icon + ".png") + if err != nil { + log.Error().Err(err).Str("icon", icon).Msg("Error reading embedded icon") + return nil, err + } + + iconPath := filepath.Join(iconTmpDir, icon+".png") + if err := os.WriteFile(iconPath, iconBytes, 0644); err != nil { + log.Error().Err(err).Str("icon", icon).Msg("Failed to write temp icon") + return nil, err + } + } + indicator := appindicator.New(application.GetApplicationID(), "wireguard_off", appindicator.CategoryApplicationStatus) - indicator.SetIconThemePath("/opt/wireguird/Icon") + indicator.SetIconThemePath(iconTmpDir) indicator.SetTitle("Wireguird") // indicator.SetLabel("Wireguird", "") indicator.SetStatus(appindicator.StatusActive) diff --git a/settings/settings.go b/settings/settings.go index 1bd063a..fad3769 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -12,19 +12,16 @@ import ( const FilePath = "./wireguird.settings" type Settings struct { - MultipleTunnels bool - StartOnTray bool - CheckUpdates bool - Debug bool + MultipleTunnels bool + StartOnTray bool + CheckUpdates bool + Debug bool + RememberConnectedServers bool + ConnectedServers map[string]int64 + CountryFlags bool + Passwordless bool } -var ( - multipleTunnels *bool - checkUpdates *bool - debug *bool - tray *bool -) - func (s *Settings) Init() error { log.Debug().Msg("Settings init") diff --git a/wireguird.glade b/wireguird.glade old mode 100755 new mode 100644 index c6fae4f..03c9fb7 --- a/wireguird.glade +++ b/wireguird.glade @@ -1,5 +1,5 @@ - + @@ -156,21 +156,18 @@ True False - /opt/wireguird/Icon/delete.png True False - /opt/wireguird/Icon/add_tunnel.png True False - /opt/wireguird/Icon/zip.png - 650 - 470 + 720 + 480 False center False @@ -519,7 +516,7 @@ True False - Interface: + Interface @@ -715,7 +712,7 @@ True False - Peer + Peer @@ -892,7 +889,7 @@ - Start in system tray + Launch minimized to system tray True True False @@ -918,6 +915,20 @@ 2 + + + Auto-reconnect to previous servers on launch + True + True + False + True + + + False + True + 3 + + True @@ -965,7 +976,35 @@ True True end - 3 + 4 + + + + + Country flags (local db 16MB, updated weekly via github.com/iplocate/ip-address-databases) + True + True + False + True + + + False + True + 4 + + + + + Enable passwordless authentication (less secure) + True + True + False + True + + + False + True + 5