This commit is contained in:
Hadi
2026-08-27 23:14:56 +02:00
commit e9c2a59ee5
41 changed files with 3680 additions and 0 deletions
+229
View File
@@ -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)
}
+74
View File
@@ -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)
}
+108
View File
@@ -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
}
+109
View File
@@ -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
}
+34
View File
@@ -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))
}
+113
View File
@@ -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
}
+103
View File
@@ -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
}