mirror of
https://github.com/anotherhadi/proton-vpn-tui.git
synced 2026-10-05 11:18:27 +02:00
init
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
ID string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Setting struct {
|
||||
Description string
|
||||
Options []Option
|
||||
Value string
|
||||
}
|
||||
|
||||
type Config map[string]Setting
|
||||
|
||||
func LoadConfig() (c Config, err error) {
|
||||
c = make(Config)
|
||||
ctx := context.Background()
|
||||
res, err := Run(ctx, "config", "list")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
inOptions := false
|
||||
for line := range strings.SplitSeq(res.Stdout, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
inOptions = false
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(fields[0], "---") {
|
||||
inOptions = true
|
||||
continue
|
||||
}
|
||||
if !inOptions {
|
||||
continue
|
||||
}
|
||||
|
||||
c[fields[0]] = Setting{Value: strings.Join(fields[1:], " ")}
|
||||
}
|
||||
|
||||
for setting := range c {
|
||||
desc, options, e := getInfo(setting)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
entry := c[setting]
|
||||
entry.Description = desc
|
||||
entry.Options = options
|
||||
c[setting] = entry
|
||||
}
|
||||
|
||||
if err = c.SaveCache(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func LoadConfigCache() (c Config, err error) {
|
||||
path, err := configCachePath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &c)
|
||||
return
|
||||
}
|
||||
|
||||
func (c Config) SaveCache() error {
|
||||
path, err := configCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
func configCachePath() (string, error) {
|
||||
cacheHome, err := xdgCacheHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(cacheHome, "proton-vpn-tui", "config.json"), nil
|
||||
}
|
||||
|
||||
func (c Config) CycleSettingUp(setting string) (err error) {
|
||||
entry, ok := c[setting]
|
||||
if !ok || len(entry.Options) == 0 {
|
||||
return errors.New("setting unknown")
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, opt := range entry.Options {
|
||||
if opt.ID == entry.Value {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next := idx + 1
|
||||
if next >= len(entry.Options) {
|
||||
next = 0
|
||||
}
|
||||
|
||||
return c.setSetting(setting, entry.Options[next].ID)
|
||||
}
|
||||
|
||||
func (c Config) CycleSettingDown(setting string) (err error) {
|
||||
entry, ok := c[setting]
|
||||
if !ok || len(entry.Options) == 0 {
|
||||
return errors.New("setting unknown")
|
||||
}
|
||||
|
||||
idx := -1
|
||||
for i, opt := range entry.Options {
|
||||
if opt.ID == entry.Value {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
prev := idx - 1
|
||||
if prev < 0 {
|
||||
prev = len(entry.Options) - 1
|
||||
}
|
||||
|
||||
return c.setSetting(setting, entry.Options[prev].ID)
|
||||
}
|
||||
|
||||
func (c Config) setSetting(setting string, value string) error {
|
||||
ctx := context.Background()
|
||||
_, err := Run(ctx, "config", "set", setting, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c[setting]
|
||||
entry.Value = value
|
||||
c[setting] = entry
|
||||
return c.SaveCache()
|
||||
}
|
||||
|
||||
func getInfo(setting string) (description string, options []Option, err error) {
|
||||
ctx := context.Background()
|
||||
res, err := Run(ctx, "config", "set", setting, "--help")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
inUsage := false
|
||||
inDescription := false
|
||||
inOptions := false
|
||||
descDone := false
|
||||
var descLines []string
|
||||
for line := range strings.SplitSeq(res.Stdout, "\n") {
|
||||
line = strings.TrimSpace(stripControl(line))
|
||||
if strings.HasPrefix(line, "Usage:") {
|
||||
inUsage = true
|
||||
continue
|
||||
}
|
||||
if line == "Values:" {
|
||||
inOptions = true
|
||||
continue
|
||||
}
|
||||
if line == "" {
|
||||
if inUsage {
|
||||
inUsage = false
|
||||
inDescription = !descDone
|
||||
} else if len(descLines) > 0 {
|
||||
descDone = true
|
||||
inDescription = false
|
||||
}
|
||||
inOptions = false
|
||||
continue
|
||||
}
|
||||
if inUsage {
|
||||
continue
|
||||
}
|
||||
if inOptions {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
options = append(options, Option{ID: fields[0], Description: strings.Join(fields[1:], " ")})
|
||||
continue
|
||||
}
|
||||
if inDescription {
|
||||
descLines = append(descLines, line)
|
||||
}
|
||||
}
|
||||
description = strings.Join(descLines, " ")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func stripControl(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func connectArgs(country, city string, p2p, secureCore, tor bool) []string {
|
||||
args := []string{"connect"}
|
||||
if country != "" {
|
||||
args = append(args, "--country", country)
|
||||
}
|
||||
if city != "" {
|
||||
args = append(args, "--city", city)
|
||||
}
|
||||
if p2p {
|
||||
args = append(args, "--p2p")
|
||||
}
|
||||
if secureCore {
|
||||
args = append(args, "--securecore")
|
||||
}
|
||||
if tor {
|
||||
args = append(args, "--tor")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func ConnectFastest(country, city string, p2p, secureCore, tor bool) (err error) {
|
||||
_, err = Run(context.Background(), connectArgs(country, city, p2p, secureCore, tor)...)
|
||||
return
|
||||
}
|
||||
|
||||
func ConnectRandom(country, city string, p2p, secureCore, tor bool) (err error) {
|
||||
args := append(connectArgs(country, city, p2p, secureCore, tor), "--random")
|
||||
_, err = Run(context.Background(), args...)
|
||||
return
|
||||
}
|
||||
|
||||
func ConnectId(id string) (err error) {
|
||||
if !isValidServerId(id) {
|
||||
return fmt.Errorf("protonvpn: invalid server id %q", id)
|
||||
}
|
||||
_, err = Run(context.Background(), "connect", id)
|
||||
return
|
||||
}
|
||||
|
||||
func ConnectAny() (err error) {
|
||||
_, err = Run(context.Background(), "connect")
|
||||
return
|
||||
}
|
||||
|
||||
func Disconnect() (err error) {
|
||||
res, err := Run(context.Background(), "disconnect")
|
||||
if err != nil && res != nil && strings.HasPrefix(res.Stdout, "Disconnected") {
|
||||
// The official CLI sometimes reports a successful disconnect and
|
||||
// then, in a separate step (e.g. cleanup/telemetry), fails and
|
||||
// exits non-zero with the generic error banner. The VPN is
|
||||
// already disconnected at that point, so treat it as success.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var serverIdPattern = regexp.MustCompile(`^[A-Z]{2}(-[A-Z0-9]{2,4})?#[0-9]+(-TOR)?$`)
|
||||
|
||||
func isValidServerId(id string) bool {
|
||||
return serverIdPattern.MatchString(id)
|
||||
}
|
||||
|
||||
func IsServerID(s string) bool {
|
||||
return isValidServerId(s)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package backend
|
||||
|
||||
import "strings"
|
||||
|
||||
var countryNames = map[string]string{
|
||||
"AD": "Andorra", "AE": "United Arab Emirates", "AF": "Afghanistan",
|
||||
"AG": "Antigua and Barbuda", "AI": "Anguilla", "AL": "Albania", "AM": "Armenia",
|
||||
"AO": "Angola", "AQ": "Antarctica", "AR": "Argentina", "AS": "American Samoa",
|
||||
"AT": "Austria", "AU": "Australia", "AW": "Aruba", "AX": "Åland Islands",
|
||||
"AZ": "Azerbaijan", "BA": "Bosnia and Herzegovina", "BB": "Barbados",
|
||||
"BD": "Bangladesh", "BE": "Belgium", "BF": "Burkina Faso", "BG": "Bulgaria",
|
||||
"BH": "Bahrain", "BI": "Burundi", "BJ": "Benin", "BL": "Saint Barthélemy",
|
||||
"BM": "Bermuda", "BN": "Brunei", "BO": "Bolivia", "BQ": "Caribbean Netherlands",
|
||||
"BR": "Brazil", "BS": "Bahamas", "BT": "Bhutan", "BV": "Bouvet Island",
|
||||
"BW": "Botswana", "BY": "Belarus", "BZ": "Belize", "CA": "Canada",
|
||||
"CC": "Cocos Islands", "CD": "DR Congo", "CF": "Central African Republic",
|
||||
"CG": "Congo", "CH": "Switzerland", "CI": "Ivory Coast", "CK": "Cook Islands",
|
||||
"CL": "Chile", "CM": "Cameroon", "CN": "China", "CO": "Colombia",
|
||||
"CR": "Costa Rica", "CU": "Cuba", "CV": "Cabo Verde", "CW": "Curaçao",
|
||||
"CX": "Christmas Island", "CY": "Cyprus", "CZ": "Czech Republic",
|
||||
"DE": "Germany", "DJ": "Djibouti", "DK": "Denmark", "DM": "Dominica",
|
||||
"DO": "Dominican Republic", "DZ": "Algeria", "EC": "Ecuador", "EE": "Estonia",
|
||||
"EG": "Egypt", "EH": "Western Sahara", "ER": "Eritrea", "ES": "Spain",
|
||||
"ET": "Ethiopia", "FI": "Finland", "FJ": "Fiji", "FK": "Falkland Islands",
|
||||
"FM": "Micronesia", "FO": "Faroe Islands", "FR": "France", "GA": "Gabon",
|
||||
"GB": "United Kingdom", "GD": "Grenada", "GE": "Georgia",
|
||||
"GF": "French Guiana", "GG": "Guernsey", "GH": "Ghana", "GI": "Gibraltar",
|
||||
"GL": "Greenland", "GM": "Gambia", "GN": "Guinea", "GP": "Guadeloupe",
|
||||
"GQ": "Equatorial Guinea", "GR": "Greece",
|
||||
"GS": "South Georgia and the South Sandwich Islands", "GT": "Guatemala",
|
||||
"GU": "Guam", "GW": "Guinea-Bissau", "GY": "Guyana", "HK": "Hong Kong",
|
||||
"HM": "Heard Island and McDonald Islands", "HN": "Honduras", "HR": "Croatia",
|
||||
"HT": "Haiti", "HU": "Hungary", "ID": "Indonesia", "IE": "Ireland",
|
||||
"IL": "Israel", "IM": "Isle of Man", "IN": "India",
|
||||
"IO": "British Indian Ocean Territory", "IQ": "Iraq", "IR": "Iran",
|
||||
"IS": "Iceland", "IT": "Italy", "JE": "Jersey", "JM": "Jamaica",
|
||||
"JO": "Jordan", "JP": "Japan", "KE": "Kenya", "KG": "Kyrgyzstan",
|
||||
"KH": "Cambodia", "KI": "Kiribati", "KM": "Comoros",
|
||||
"KN": "Saint Kitts and Nevis", "KP": "North Korea", "KR": "South Korea",
|
||||
"KW": "Kuwait", "KY": "Cayman Islands", "KZ": "Kazakhstan", "LA": "Laos",
|
||||
"LB": "Lebanon", "LC": "Saint Lucia", "LI": "Liechtenstein",
|
||||
"LK": "Sri Lanka", "LR": "Liberia", "LS": "Lesotho", "LT": "Lithuania",
|
||||
"LU": "Luxembourg", "LV": "Latvia", "LY": "Libya", "MA": "Morocco",
|
||||
"MC": "Monaco", "MD": "Moldova", "ME": "Montenegro", "MF": "Saint Martin",
|
||||
"MG": "Madagascar", "MH": "Marshall Islands", "MK": "North Macedonia",
|
||||
"ML": "Mali", "MM": "Myanmar", "MN": "Mongolia", "MO": "Macao",
|
||||
"MP": "Northern Mariana Islands", "MQ": "Martinique", "MR": "Mauritania",
|
||||
"MS": "Montserrat", "MT": "Malta", "MU": "Mauritius", "MV": "Maldives",
|
||||
"MW": "Malawi", "MX": "Mexico", "MY": "Malaysia", "MZ": "Mozambique",
|
||||
"NA": "Namibia", "NC": "New Caledonia", "NE": "Niger",
|
||||
"NF": "Norfolk Island", "NG": "Nigeria", "NI": "Nicaragua",
|
||||
"NL": "Netherlands", "NO": "Norway", "NP": "Nepal", "NR": "Nauru",
|
||||
"NU": "Niue", "NZ": "New Zealand", "OM": "Oman", "PA": "Panama",
|
||||
"PE": "Peru", "PF": "French Polynesia", "PG": "Papua New Guinea",
|
||||
"PH": "Philippines", "PK": "Pakistan", "PL": "Poland",
|
||||
"PM": "Saint Pierre and Miquelon", "PN": "Pitcairn Islands",
|
||||
"PR": "Puerto Rico", "PS": "Palestine", "PT": "Portugal", "PW": "Palau",
|
||||
"PY": "Paraguay", "QA": "Qatar", "RE": "Réunion", "RO": "Romania",
|
||||
"RS": "Serbia", "RU": "Russia", "RW": "Rwanda", "SA": "Saudi Arabia",
|
||||
"SB": "Solomon Islands", "SC": "Seychelles", "SD": "Sudan", "SE": "Sweden",
|
||||
"SG": "Singapore", "SH": "Saint Helena", "SI": "Slovenia",
|
||||
"SJ": "Svalbard and Jan Mayen", "SK": "Slovakia", "SL": "Sierra Leone",
|
||||
"SM": "San Marino", "SN": "Senegal", "SO": "Somalia", "SR": "Suriname",
|
||||
"SS": "South Sudan", "ST": "São Tomé and Príncipe", "SV": "El Salvador",
|
||||
"SX": "Sint Maarten", "SY": "Syria", "SZ": "Eswatini",
|
||||
"TC": "Turks and Caicos Islands", "TD": "Chad",
|
||||
"TF": "French Southern Territories", "TG": "Togo", "TH": "Thailand",
|
||||
"TJ": "Tajikistan", "TK": "Tokelau", "TL": "Timor-Leste",
|
||||
"TM": "Turkmenistan", "TN": "Tunisia", "TO": "Tonga", "TR": "Turkey",
|
||||
"TT": "Trinidad and Tobago", "TV": "Tuvalu", "TW": "Taiwan",
|
||||
"TZ": "Tanzania", "UA": "Ukraine", "UG": "Uganda",
|
||||
"UM": "United States Minor Outlying Islands", "US": "United States",
|
||||
"UY": "Uruguay", "UZ": "Uzbekistan", "VA": "Vatican City",
|
||||
"VC": "Saint Vincent and the Grenadines", "VE": "Venezuela",
|
||||
"VG": "British Virgin Islands", "VI": "United States Virgin Islands",
|
||||
"VN": "Vietnam", "VU": "Vanuatu", "WF": "Wallis and Futuna",
|
||||
"WS": "Samoa", "YE": "Yemen", "YT": "Mayotte", "ZA": "South Africa",
|
||||
"ZM": "Zambia", "ZW": "Zimbabwe",
|
||||
}
|
||||
|
||||
func CountryName(countryCode string) string {
|
||||
code := strings.ToUpper(countryCode)
|
||||
if code == "UK" {
|
||||
code = "GB"
|
||||
}
|
||||
if name, ok := countryNames[code]; ok {
|
||||
return name
|
||||
}
|
||||
return countryCode
|
||||
}
|
||||
|
||||
func ResolveCountryCode(s string) (string, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(s))
|
||||
if code == "UK" {
|
||||
code = "GB"
|
||||
}
|
||||
if _, ok := countryNames[code]; ok {
|
||||
return code, true
|
||||
}
|
||||
|
||||
name := strings.ToLower(strings.TrimSpace(s))
|
||||
for c, n := range countryNames {
|
||||
if strings.ToLower(n) == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultTimeout = 15 * time.Second
|
||||
|
||||
var BinaryName = "protonvpn"
|
||||
|
||||
var ErrTimeout = errors.New("protonvpn: command timeout")
|
||||
|
||||
var ErrNotFound = errors.New("protonvpn: binary not found in PATH")
|
||||
|
||||
type Result struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
type CommandError struct {
|
||||
Result *Result
|
||||
Err error
|
||||
}
|
||||
|
||||
const unexpectedErrorBanner = "An unexpected error occurred. Please try again."
|
||||
|
||||
func (e *CommandError) Error() string {
|
||||
if strings.HasPrefix(e.Result.Stderr, unexpectedErrorBanner) ||
|
||||
strings.HasPrefix(e.Result.Stdout, unexpectedErrorBanner) {
|
||||
return unexpectedErrorBanner
|
||||
}
|
||||
if e.Result.Stderr != "" {
|
||||
return fmt.Sprintf("%v (stderr: %q)", e.Err, e.Result.Stderr)
|
||||
}
|
||||
if e.Result.Stdout != "" {
|
||||
return e.Result.Stdout
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *CommandError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func IsAuthRequired(err error) bool {
|
||||
var cmdErr *CommandError
|
||||
if !errors.As(err, &cmdErr) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(cmdErr.Result.Stderr, "protonvpn signin") ||
|
||||
strings.Contains(cmdErr.Result.Stdout, "protonvpn signin")
|
||||
}
|
||||
|
||||
func IsFreePlanRestricted(err error) bool {
|
||||
var cmdErr *CommandError
|
||||
if !errors.As(err, &cmdErr) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(cmdErr.Result.Stderr, "not available on the free plan") ||
|
||||
strings.Contains(cmdErr.Result.Stdout, "not available on the free plan")
|
||||
}
|
||||
|
||||
func Available() error {
|
||||
if _, err := exec.LookPath(BinaryName); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, args ...string) (*Result, error) {
|
||||
if ctx == nil {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, BinaryName, args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
runErr := cmd.Run()
|
||||
|
||||
res := &Result{
|
||||
Stdout: strings.TrimSpace(stdout.String()),
|
||||
Stderr: strings.TrimSpace(stderr.String()),
|
||||
}
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return res, &CommandError{Result: res, Err: ErrTimeout}
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return res, &CommandError{Result: res, Err: runErr}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(res.Stdout, "Error:") {
|
||||
return res, &CommandError{Result: res, Err: errors.New(res.Stdout)}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package backend
|
||||
|
||||
import "strings"
|
||||
|
||||
func countryCodeFromServerId(id string) string {
|
||||
if i := strings.IndexAny(id, "-#"); i != -1 {
|
||||
return id[:i]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func FlagEmoji(countryCode string) string {
|
||||
code := strings.ToUpper(countryCode)
|
||||
if code == "UK" {
|
||||
code = "GB"
|
||||
}
|
||||
if len(code) != 2 {
|
||||
return "🏳️"
|
||||
}
|
||||
|
||||
var flag strings.Builder
|
||||
for _, r := range code {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return "🏳️"
|
||||
}
|
||||
flag.WriteRune(r + 127397)
|
||||
}
|
||||
|
||||
return flag.String()
|
||||
}
|
||||
|
||||
func FlagEmojiFromServerId(serverId string) string {
|
||||
return FlagEmoji(countryCodeFromServerId(serverId))
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
featureSecureCore = 1 << iota
|
||||
featureTor
|
||||
featureP2P
|
||||
featureStreaming
|
||||
featureIPv6
|
||||
)
|
||||
|
||||
type LogicalServer struct {
|
||||
Name string
|
||||
EntryCountry string
|
||||
ExitCountry string
|
||||
City *string
|
||||
Tier int
|
||||
SecureCore bool
|
||||
Tor bool
|
||||
P2P bool
|
||||
Streaming bool
|
||||
IPv6 bool
|
||||
Score float64
|
||||
ID string
|
||||
Status int
|
||||
Load int
|
||||
}
|
||||
|
||||
func (s LogicalServer) IsFree() bool {
|
||||
return s.Tier == 0
|
||||
}
|
||||
|
||||
func (s *LogicalServer) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Name string
|
||||
EntryCountry string
|
||||
ExitCountry string
|
||||
City *string
|
||||
Tier int
|
||||
Features int
|
||||
Score float64
|
||||
ID string
|
||||
Status int
|
||||
Load int
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*s = LogicalServer{
|
||||
Name: raw.Name,
|
||||
EntryCountry: raw.EntryCountry,
|
||||
ExitCountry: raw.ExitCountry,
|
||||
City: raw.City,
|
||||
Tier: raw.Tier,
|
||||
SecureCore: raw.Features&featureSecureCore != 0,
|
||||
Tor: raw.Features&featureTor != 0,
|
||||
P2P: raw.Features&featureP2P != 0,
|
||||
Streaming: raw.Features&featureStreaming != 0,
|
||||
IPv6: raw.Features&featureIPv6 != 0,
|
||||
Score: raw.Score,
|
||||
ID: raw.ID,
|
||||
Status: raw.Status,
|
||||
Load: raw.Load,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RefreshServerList() error {
|
||||
_, err := Run(context.Background(), "cities", "list", "France")
|
||||
return err
|
||||
}
|
||||
|
||||
func ParseCache() ([]LogicalServer, error) {
|
||||
cacheHome, err := xdgCacheHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := filepath.Join(cacheHome, "Proton", "VPN", "serverlist.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cache struct {
|
||||
LogicalServers []LogicalServer
|
||||
}
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cache.LogicalServers, nil
|
||||
}
|
||||
|
||||
func xdgCacheHome() (string, error) {
|
||||
if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(home, ".cache"), nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const protonInterface = "proton0"
|
||||
|
||||
var ErrNmcliNotFound = errors.New("nmcli: binary not found in PATH")
|
||||
|
||||
type ConnectionStatus struct {
|
||||
Connected bool
|
||||
Server string
|
||||
Location string
|
||||
Load int
|
||||
Protocol string
|
||||
}
|
||||
|
||||
func Status() (ConnectionStatus, error) {
|
||||
connected, err := isConnectedViaNetworkManager()
|
||||
if err != nil {
|
||||
return ConnectionStatus{}, err
|
||||
}
|
||||
if !connected {
|
||||
return ConnectionStatus{Connected: false}, nil
|
||||
}
|
||||
|
||||
res, err := Run(context.Background(), "status")
|
||||
if err != nil {
|
||||
return ConnectionStatus{}, err
|
||||
}
|
||||
|
||||
status := parseStatus(res.Stdout)
|
||||
status.Connected = true
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func IsConnected() (bool, error) {
|
||||
return isConnectedViaNetworkManager()
|
||||
}
|
||||
|
||||
func isConnectedViaNetworkManager() (bool, error) {
|
||||
if _, err := exec.LookPath("nmcli"); err != nil {
|
||||
return false, ErrNmcliNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "nmcli", "-t", "-f", "DEVICE", "connection", "show", "--active")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false, fmt.Errorf("nmcli: %w (stderr: %q)", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
for device := range strings.SplitSeq(strings.TrimSpace(stdout.String()), "\n") {
|
||||
if device == protonInterface {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func parseStatus(output string) ConnectionStatus {
|
||||
var status ConnectionStatus
|
||||
|
||||
for line := range strings.SplitSeq(output, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), ": ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "Status":
|
||||
status.Connected = value == "Connected"
|
||||
case "Server":
|
||||
if server, location, ok := strings.Cut(value, " in "); ok {
|
||||
status.Server = server
|
||||
status.Location = location
|
||||
} else {
|
||||
status.Server = value
|
||||
}
|
||||
case "Load":
|
||||
if load, err := strconv.Atoi(strings.TrimSuffix(value, "%")); err == nil {
|
||||
status.Load = load
|
||||
}
|
||||
case "Protocol":
|
||||
status.Protocol = value
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
Reference in New Issue
Block a user