diff --git a/.github/ISSUE_TEMPLATE/provider.md b/.github/ISSUE_TEMPLATE/provider.md index 5d2ffb64..e27ff6a2 100644 --- a/.github/ISSUE_TEMPLATE/provider.md +++ b/.github/ISSUE_TEMPLATE/provider.md @@ -13,4 +13,5 @@ One of the following is required: - Publicly accessible URL to the list of servers **and attach** an example Openvpn configuration file for both TCP and UDP If the list of servers requires to login **or** is hidden behind an interactive configurator, -it's not possible to support the provider yet. Please instead subscribe to issue #223 which would solve it. +it's not possible to support the provider yet. +[The Wiki](https://github.com/qdm12/gluetun/wiki/Openvpn-file) describes how to use an Openvpn configuration file. diff --git a/Dockerfile b/Dockerfile index caaea798..f85b5c10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,6 +69,7 @@ ENV VPNSP=pia \ OPENVPN_ROOT=yes \ OPENVPN_TARGET_IP= \ OPENVPN_IPV6=off \ + OPENVPN_CUSTOM_CONFIG= \ TZ= \ PUID= \ PGID= \ diff --git a/internal/configuration/openvpn.go b/internal/configuration/openvpn.go index a29820c2..f58762a4 100644 --- a/internal/configuration/openvpn.go +++ b/internal/configuration/openvpn.go @@ -20,6 +20,7 @@ type OpenVPN struct { Cipher string `json:"cipher"` Auth string `json:"auth"` Provider Provider `json:"provider"` + Config string `json:"custom_config"` } func (settings *OpenVPN) String() string { @@ -42,6 +43,10 @@ func (settings *OpenVPN) lines() (lines []string) { lines = append(lines, indent+lastIndent+"Custom auth algorithm: "+settings.Auth) } + if len(settings.Config) > 0 { + lines = append(lines, indent+lastIndent+"Custom configuration: "+settings.Config) + } + lines = append(lines, indent+lastIndent+"Provider:") for _, line := range settings.Provider.lines() { lines = append(lines, indent+indent+line) @@ -69,7 +74,14 @@ func (settings *OpenVPN) read(r reader) (err error) { settings.Provider.Name = vpnsp - settings.User, err = r.getFromEnvOrSecretFile("OPENVPN_USER", true, []string{"USER"}) + settings.Config, err = r.env.Get("OPENVPN_CUSTOM_CONFIG", params.CaseSensitiveValue()) + if err != nil { + return err + } + + credentialsRequired := len(settings.Config) == 0 + + settings.User, err = r.getFromEnvOrSecretFile("OPENVPN_USER", credentialsRequired, []string{"USER"}) if err != nil { return err } @@ -79,7 +91,7 @@ func (settings *OpenVPN) read(r reader) (err error) { if settings.Provider.Name == constants.Mullvad { settings.Password = "m" } else { - settings.Password, err = r.getFromEnvOrSecretFile("OPENVPN_PASSWORD", true, []string{"PASSWORD"}) + settings.Password, err = r.getFromEnvOrSecretFile("OPENVPN_PASSWORD", credentialsRequired, []string{"PASSWORD"}) if err != nil { return err } diff --git a/internal/configuration/openvpn_test.go b/internal/configuration/openvpn_test.go index 7f672216..987b71e6 100644 --- a/internal/configuration/openvpn_test.go +++ b/internal/configuration/openvpn_test.go @@ -19,7 +19,7 @@ func Test_OpenVPN_JSON(t *testing.T) { data, err := json.Marshal(in) require.NoError(t, err) //nolint:lll - assert.Equal(t, `{"user":"","password":"","verbosity":0,"mssfix":0,"run_as_root":true,"cipher":"","auth":"","provider":{"name":"name","server_selection":{"network_protocol":"","regions":null,"group":"","countries":null,"cities":null,"hostnames":null,"isps":null,"owned":false,"custom_port":0,"numbers":null,"encryption_preset":""},"extra_config":{"encryption_preset":"","openvpn_ipv6":false},"port_forwarding":{"enabled":false,"filepath":""}}}`, string(data)) + assert.Equal(t, `{"user":"","password":"","verbosity":0,"mssfix":0,"run_as_root":true,"cipher":"","auth":"","provider":{"name":"name","server_selection":{"network_protocol":"","regions":null,"group":"","countries":null,"cities":null,"hostnames":null,"isps":null,"owned":false,"custom_port":0,"numbers":null,"encryption_preset":""},"extra_config":{"encryption_preset":"","openvpn_ipv6":false},"port_forwarding":{"enabled":false,"filepath":""}},"custom_config":""}`, string(data)) var out OpenVPN err = json.Unmarshal(data, &out) require.NoError(t, err) diff --git a/internal/openvpn/custom.go b/internal/openvpn/custom.go new file mode 100644 index 00000000..fad6f68c --- /dev/null +++ b/internal/openvpn/custom.go @@ -0,0 +1,205 @@ +package openvpn + +import ( + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + + "github.com/qdm12/gluetun/internal/configuration" + "github.com/qdm12/gluetun/internal/constants" + "github.com/qdm12/gluetun/internal/models" + "github.com/qdm12/golibs/os" +) + +var errProcessCustomConfig = errors.New("cannot process custom config") + +func (l *looper) processCustomConfig(settings configuration.OpenVPN) ( + lines []string, connection models.OpenVPNConnection, err error) { + lines, err = readCustomConfigLines(settings.Config, l.openFile) + if err != nil { + return nil, connection, fmt.Errorf("%w: %s", errProcessCustomConfig, err) + } + + lines = modifyCustomConfig(lines, l.username, settings) + + connection, err = extractConnectionFromLines(lines) + if err != nil { + return nil, connection, fmt.Errorf("%w: %s", errProcessCustomConfig, err) + } + + lines = setConnectionToLines(lines, connection) + return lines, connection, nil +} + +func readCustomConfigLines(filepath string, openFile os.OpenFileFunc) ( + lines []string, err error) { + file, err := openFile(filepath, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer file.Close() + + b, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + if err := file.Close(); err != nil { + return nil, err + } + + return strings.Split(string(b), "\n"), nil +} + +func modifyCustomConfig(lines []string, username string, + settings configuration.OpenVPN) (modified []string) { + // Remove some lines + for _, line := range lines { + switch { + case strings.HasPrefix(line, "up "), + strings.HasPrefix(line, "down "), + strings.HasPrefix(line, "verb "), + strings.HasPrefix(line, "auth-user-pass "), + len(settings.Cipher) > 0 && strings.HasPrefix(line, "cipher "), + len(settings.Auth) > 0 && strings.HasPrefix(line, "auth "), + settings.MSSFix > 0 && strings.HasPrefix(line, "mssfix "), + !settings.Provider.ExtraConfigOptions.OpenVPNIPv6 && strings.HasPrefix(line, "tun-ipv6"): + default: + modified = append(modified, line) + } + } + + // Add values + modified = append(modified, "mute-replay-warnings") + modified = append(modified, "auth-nocache") + modified = append(modified, "pull-filter ignore \"auth-token\"") // prevent auth failed loop + modified = append(modified, `pull-filter ignore "ping-restart"`) + modified = append(modified, "auth-retry nointeract") + modified = append(modified, "suppress-timestamps") + modified = append(modified, "auth-user-pass "+constants.OpenVPNAuthConf) + modified = append(modified, "verb "+strconv.Itoa(settings.Verbosity)) + if len(settings.Cipher) > 0 { + modified = append(modified, "cipher "+settings.Cipher) + } + if len(settings.Auth) > 0 { + modified = append(modified, "auth "+settings.Auth) + } + if settings.MSSFix > 0 { + modified = append(modified, "mssfix "+strconv.Itoa(int(settings.MSSFix))) + } + if !settings.Provider.ExtraConfigOptions.OpenVPNIPv6 { + modified = append(modified, `pull-filter ignore "route-ipv6"`) + modified = append(modified, `pull-filter ignore "ifconfig-ipv6"`) + } + if !settings.Root { + modified = append(modified, "user "+username) + } + + return modified +} + +var errExtractConnection = errors.New("cannot extract connection") + +// extractConnectionFromLines always takes the first remote line only. +func extractConnectionFromLines(lines []string) ( //nolint:gocognit + connection models.OpenVPNConnection, err error) { + for _, line := range lines { + switch { + case strings.HasPrefix(line, "proto "): + fields := strings.Fields(line) + if n := len(fields); n != 2 { //nolint:gomnd + return connection, fmt.Errorf( + "%w: proto line has %d fields instead of 2: %s", + errExtractConnection, n, line) + } + connection.Protocol = fields[1] + + // only take the first remote line + case strings.HasPrefix(line, "remote ") && connection.IP == nil: + fields := strings.Fields(line) + n := len(fields) + //nolint:gomnd + if n < 2 { + return connection, fmt.Errorf( + "%w: remote line has not enough fields: %s", + errExtractConnection, line) + } + + host := fields[1] + if ip := net.ParseIP(host); ip != nil { + connection.IP = ip + } else { + return connection, fmt.Errorf( + "%w: for now, the remote line must contain an IP adddress: %s", + errExtractConnection, line) + // TODO resolve hostname once there is an option to allow it through + // the firewall before the VPN is up. + } + + if n > 2 { //nolint:gomnd + port, err := strconv.Atoi(fields[2]) + if err != nil { + return connection, fmt.Errorf( + "%w: remote line has an invalid port: %s", + errExtractConnection, line) + } + connection.Port = uint16(port) + } + + if n > 3 { //nolint:gomnd + connection.Protocol = strings.ToLower(fields[3]) + } + + if n > 4 { //nolint:gomnd + return connection, fmt.Errorf( + "%w: remote line has too many fields: %s", + errExtractConnection, line) + } + } + + if connection.Protocol != "" && connection.IP != nil { + break + } + } + + if connection.IP == nil { + return connection, fmt.Errorf("%w: remote line not found", errExtractConnection) + } + + switch connection.Protocol { + case "": + return connection, fmt.Errorf("%w: network protocol not found", errExtractConnection) + case "tcp", "udp": + default: + return connection, fmt.Errorf("%w: network protocol not supported: %s", errExtractConnection, connection.Protocol) + } + + if connection.Port == 0 { + if connection.Protocol == "tcp" { + const defaultPort uint16 = 443 + connection.Port = defaultPort + } else { + const defaultPort uint16 = 1194 + connection.Port = defaultPort + } + } + + return connection, nil +} + +func setConnectionToLines(lines []string, connection models.OpenVPNConnection) (modified []string) { + for i, line := range lines { + switch { + case strings.HasPrefix(line, "proto "): + lines[i] = "proto " + connection.Protocol + + case strings.HasPrefix(line, "remote "): + lines[i] = "remote " + connection.IP.String() + " " + strconv.Itoa(int(connection.Port)) + } + } + + return lines +} diff --git a/internal/openvpn/loop.go b/internal/openvpn/loop.go index 37faf47c..750a5be3 100644 --- a/internal/openvpn/loop.go +++ b/internal/openvpn/loop.go @@ -99,7 +99,7 @@ func (l *looper) signalCrashedStatus() { } } -func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) { +func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) { //nolint:gocognit defer wg.Done() select { case <-l.start: @@ -110,15 +110,29 @@ func (l *looper) Run(ctx context.Context, wg *sync.WaitGroup) { for ctx.Err() == nil { settings, allServers := l.state.getSettingsAndServers() + providerConf := provider.New(settings.Provider.Name, allServers, time.Now) - connection, err := providerConf.GetOpenVPNConnection(settings.Provider.ServerSelection) - if err != nil { - l.logger.Error(err) - l.signalCrashedStatus() - l.cancel() - return + + var connection models.OpenVPNConnection + var lines []string + var err error + if len(settings.Config) == 0 { + connection, err = providerConf.GetOpenVPNConnection(settings.Provider.ServerSelection) + if err != nil { + l.logger.Error(err) + l.signalCrashedStatus() + l.cancel() + return + } + lines = providerConf.BuildConf(connection, l.username, settings) + } else { + lines, connection, err = l.processCustomConfig(settings) + if err != nil { + l.signalCrashedStatus() + l.logAndWait(ctx, err) + continue + } } - lines := providerConf.BuildConf(connection, l.username, settings) if err := writeOpenvpnConf(lines, l.openFile); err != nil { l.logger.Error(err)