feat(vpn): VPN_UP_COMMAND and VPN_DOWN_COMMAND options

This commit is contained in:
Quentin McGaw
2026-03-08 16:06:16 +00:00
parent c0af198155
commit 57c53bc19e
14 changed files with 152 additions and 68 deletions
+2
View File
@@ -634,6 +634,8 @@ type RunStarter interface {
Run(cmd *exec.Cmd) (output string, err error)
Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string,
waitError <-chan error, err error)
RunAndLog(ctx context.Context, commandString string,
logger command.Logger) (err error)
}
const gluetunLogo = ` @@@
+6
View File
@@ -0,0 +1,6 @@
package command
type Logger interface {
Info(s string)
Error(s string)
}
+11 -11
View File
@@ -9,13 +9,13 @@ import (
)
var (
ErrCommandEmpty = errors.New("command is empty")
ErrSingleQuoteUnterminated = errors.New("unterminated single-quoted string")
ErrDoubleQuoteUnterminated = errors.New("unterminated double-quoted string")
ErrEscapeUnterminated = errors.New("unterminated backslash-escape")
errCommandEmpty = errors.New("command is empty")
errSingleQuoteUnterminated = errors.New("unterminated single-quoted string")
errDoubleQuoteUnterminated = errors.New("unterminated double-quoted string")
errEscapeUnterminated = errors.New("unterminated backslash-escape")
)
// Split splits a command string into a slice of arguments.
// split splits a command string into a slice of arguments.
// This is especially important for commands such as:
// /bin/sh -c "echo hello"
// which should be split into: ["/bin/sh", "-c", "echo hello"]
@@ -23,9 +23,9 @@ var (
// It does not support:
// - the $" quoting style.
// - expansion (brace, shell or pathname).
func Split(command string) (words []string, err error) {
func split(command string) (words []string, err error) {
if command == "" {
return nil, fmt.Errorf("%w", ErrCommandEmpty)
return nil, fmt.Errorf("%w", errCommandEmpty)
}
const bufferSize = 1024
@@ -42,7 +42,7 @@ func Split(command string) (words []string, err error) {
case character == '\\':
// Look ahead to eventually skip an escaped newline
if command[startIndex+runeSize:] == "" {
return nil, fmt.Errorf("%w: %q", ErrEscapeUnterminated, command)
return nil, fmt.Errorf("%w: %q", errEscapeUnterminated, command)
}
character, runeSize := utf8.DecodeRuneInString(command[startIndex+runeSize:])
if character == '\n' {
@@ -119,7 +119,7 @@ func handleDoubleQuoted(input string, startIndex int, buffer *bytes.Buffer) (
startIndex = cursor
}
}
return "", 0, fmt.Errorf("%w", ErrDoubleQuoteUnterminated)
return "", 0, fmt.Errorf("%w", errDoubleQuoteUnterminated)
}
func handleSingleQuoted(input string, startIndex int, buffer *bytes.Buffer) (
@@ -127,7 +127,7 @@ func handleSingleQuoted(input string, startIndex int, buffer *bytes.Buffer) (
) {
closingQuoteIndex := strings.IndexRune(input[startIndex:], '\'')
if closingQuoteIndex == -1 {
return "", 0, fmt.Errorf("%w", ErrSingleQuoteUnterminated)
return "", 0, fmt.Errorf("%w", errSingleQuoteUnterminated)
}
buffer.WriteString(input[startIndex : startIndex+closingQuoteIndex])
const singleQuoteRuneLength = 1
@@ -139,7 +139,7 @@ func handleEscaped(input string, startIndex int, buffer *bytes.Buffer) (
word string, newStartIndex int, err error,
) {
if input[startIndex:] == "" {
return "", 0, fmt.Errorf("%w", ErrEscapeUnterminated)
return "", 0, fmt.Errorf("%w", errEscapeUnterminated)
}
character, runeLength := utf8.DecodeRuneInString(input[startIndex:])
if character != '\n' { // backslash-escaped newline is ignored
+7 -7
View File
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func Test_Split(t *testing.T) {
func Test_split(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
@@ -17,7 +17,7 @@ func Test_Split(t *testing.T) {
}{
"empty": {
command: "",
errWrapped: ErrCommandEmpty,
errWrapped: errCommandEmpty,
errMessage: "command is empty",
},
"concrete_sh_command": {
@@ -74,22 +74,22 @@ func Test_Split(t *testing.T) {
},
"unterminated_single_quote": {
command: "'abc'\\''def",
errWrapped: ErrSingleQuoteUnterminated,
errWrapped: errSingleQuoteUnterminated,
errMessage: `splitting word in "'abc'\\''def": unterminated single-quoted string`,
},
"unterminated_double_quote": {
command: "\"abc'def",
errWrapped: ErrDoubleQuoteUnterminated,
errWrapped: errDoubleQuoteUnterminated,
errMessage: `splitting word in "\"abc'def": unterminated double-quoted string`,
},
"unterminated_escape": {
command: "abc\\",
errWrapped: ErrEscapeUnterminated,
errWrapped: errEscapeUnterminated,
errMessage: `splitting word in "abc\\": unterminated backslash-escape`,
},
"unterminated_escape_only": {
command: " \\",
errWrapped: ErrEscapeUnterminated,
errWrapped: errEscapeUnterminated,
errMessage: `unterminated backslash-escape: " \\"`,
},
}
@@ -98,7 +98,7 @@ func Test_Split(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()
words, err := Split(testCase.command)
words, err := split(testCase.command)
assert.Equal(t, testCase.words, words)
assert.ErrorIs(t, err, testCase.errWrapped)
+48
View File
@@ -0,0 +1,48 @@
package command
import (
"context"
"fmt"
"os/exec"
)
func (c *Cmder) RunAndLog(ctx context.Context, command string, logger Logger) (err error) {
args, err := split(command)
if err != nil {
return fmt.Errorf("parsing command: %w", err)
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec G204
stdout, stderr, waitError, err := c.Start(cmd)
if err != nil {
return err
}
streamCtx, streamCancel := context.WithCancel(context.Background())
streamDone := make(chan struct{})
go streamLines(streamCtx, streamDone, logger, stdout, stderr)
err = <-waitError
streamCancel()
<-streamDone
return err
}
func streamLines(ctx context.Context, done chan<- struct{},
logger Logger, stdout, stderr <-chan string,
) {
defer close(done)
var line string
for {
select {
case <-ctx.Done():
return
case line = <-stdout:
logger.Info(line)
case line = <-stderr:
logger.Error(line)
}
}
}
+30 -5
View File
@@ -19,6 +19,14 @@ type VPN struct {
OpenVPN OpenVPN `json:"openvpn"`
Wireguard Wireguard `json:"wireguard"`
PMTUD PMTUD `json:"pmtud"`
// UpCommand is the command to use when the VPN connection is up.
// It can be the empty string to indicate not to run a command.
// It cannot be nil in the internal state.
UpCommand *string `json:"up_command"`
// DownCommand is the command to use after the VPN connection goes down.
// It can be the empty string to indicate to NOT run a command.
// It cannot be nil in the internal state.
DownCommand *string `json:"down_command"`
}
// TODO v4 remove pointer for receiver (because of Surfshark).
@@ -56,11 +64,13 @@ func (v *VPN) Validate(filterChoicesGetter FilterChoicesGetter, ipv6Supported bo
func (v *VPN) Copy() (copied VPN) {
return VPN{
Type: v.Type,
Provider: v.Provider.copy(),
OpenVPN: v.OpenVPN.copy(),
Wireguard: v.Wireguard.copy(),
PMTUD: v.PMTUD.copy(),
Type: v.Type,
Provider: v.Provider.copy(),
OpenVPN: v.OpenVPN.copy(),
Wireguard: v.Wireguard.copy(),
PMTUD: v.PMTUD.copy(),
UpCommand: gosettings.CopyPointer(v.UpCommand),
DownCommand: gosettings.CopyPointer(v.DownCommand),
}
}
@@ -70,6 +80,8 @@ func (v *VPN) OverrideWith(other VPN) {
v.OpenVPN.overrideWith(other.OpenVPN)
v.Wireguard.overrideWith(other.Wireguard)
v.PMTUD.overrideWith(other.PMTUD)
v.UpCommand = gosettings.OverrideWithPointer(v.UpCommand, other.UpCommand)
v.DownCommand = gosettings.OverrideWithPointer(v.DownCommand, other.DownCommand)
}
func (v *VPN) setDefaults() {
@@ -78,6 +90,8 @@ func (v *VPN) setDefaults() {
v.OpenVPN.setDefaults(v.Provider.Name)
v.Wireguard.setDefaults(v.Provider.Name)
v.PMTUD.setDefaults()
v.UpCommand = gosettings.DefaultPointer(v.UpCommand, "")
v.DownCommand = gosettings.DefaultPointer(v.DownCommand, "")
}
func (v VPN) String() string {
@@ -96,6 +110,13 @@ func (v VPN) toLinesNode() (node *gotree.Node) {
}
node.AppendNode(v.PMTUD.toLinesNode())
if *v.UpCommand != "" {
node.Appendf("Up command: %s", *v.UpCommand)
}
if *v.DownCommand != "" {
node.Appendf("Down command: %s", *v.DownCommand)
}
return node
}
@@ -122,5 +143,9 @@ func (v *VPN) read(r *reader.Reader) (err error) {
return fmt.Errorf("PMTUD: %w", err)
}
v.UpCommand = r.Get("VPN_UP_COMMAND", reader.ForceLowercase(false))
v.DownCommand = r.Get("VPN_DOWN_COMMAND", reader.ForceLowercase(false))
return nil
}
+3
View File
@@ -4,6 +4,8 @@ import (
"context"
"net/netip"
"os/exec"
"github.com/qdm12/gluetun/internal/command"
)
type Service interface {
@@ -35,4 +37,5 @@ type Logger interface {
type Cmder interface {
Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string,
waitError <-chan error, startErr error)
RunAndLog(ctx context.Context, commandString string, logger command.Logger) (err error)
}
+1 -41
View File
@@ -3,10 +3,7 @@ package service
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/qdm12/gluetun/internal/command"
)
func runCommand(ctx context.Context, cmder Cmder, logger Logger,
@@ -20,42 +17,5 @@ func runCommand(ctx context.Context, cmder Cmder, logger Logger,
commandString := strings.ReplaceAll(commandTemplate, "{{PORTS}}", portsString)
commandString = strings.ReplaceAll(commandString, "{{PORT}}", portStrings[0])
commandString = strings.ReplaceAll(commandString, "{{VPN_INTERFACE}}", vpnInterface)
args, err := command.Split(commandString)
if err != nil {
return fmt.Errorf("parsing command: %w", err)
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec G204
stdout, stderr, waitError, err := cmder.Start(cmd)
if err != nil {
return err
}
streamCtx, streamCancel := context.WithCancel(context.Background())
streamDone := make(chan struct{})
go streamLines(streamCtx, streamDone, logger, stdout, stderr)
err = <-waitError
streamCancel()
<-streamDone
return err
}
func streamLines(ctx context.Context, done chan<- struct{},
logger Logger, stdout, stderr <-chan string,
) {
defer close(done)
var line string
for {
select {
case <-ctx.Done():
return
case line = <-stdout:
logger.Info(line)
case line = <-stderr:
logger.Error(line)
}
}
return cmder.RunAndLog(ctx, commandString, logger)
}
+2 -3
View File
@@ -3,8 +3,8 @@ package service
import (
"context"
"net/netip"
"os/exec"
"github.com/qdm12/gluetun/internal/command"
"github.com/qdm12/gluetun/internal/provider/utils"
)
@@ -35,6 +35,5 @@ type PortForwarder interface {
}
type Cmder interface {
Start(cmd *exec.Cmd) (stdoutLines, stderrLines <-chan string,
waitError <-chan error, startErr error)
RunAndLog(ctx context.Context, command string, logger command.Logger) (err error)
}
+25 -1
View File
@@ -3,9 +3,22 @@ package vpn
import (
"context"
"errors"
"strings"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/constants/vpn"
)
func (l *Loop) cleanup() {
settings := l.GetSettings()
var err error
commandString := strings.ReplaceAll(*settings.DownCommand, "{{VPN_INTERFACE}}", getVPNInterface(settings))
err = l.cmder.RunAndLog(context.Background(), commandString, l.logger)
if err != nil {
l.logger.Error("failed to run VPN down command: " + err.Error())
}
for _, vpnPort := range l.vpnInputPorts {
err := l.fw.RemoveAllowedPort(context.Background(), vpnPort)
if err != nil {
@@ -13,7 +26,7 @@ func (l *Loop) cleanup() {
}
}
err := l.publicip.ClearData()
err = l.publicip.ClearData()
if err != nil {
l.logger.Error("clearing public IP data: " + err.Error())
}
@@ -31,3 +44,14 @@ func (l *Loop) cleanup() {
l.logger.Error("stopping boring poll: " + err.Error())
}
}
func getVPNInterface(settings settings.VPN) string {
switch settings.Type {
case vpn.OpenVPN:
return settings.OpenVPN.Interface
case vpn.Wireguard:
return settings.Wireguard.Interface
default:
panic("invalid VPN type: " + settings.Type)
}
}
+5
View File
@@ -5,6 +5,7 @@ import (
"net/netip"
"os/exec"
"github.com/qdm12/gluetun/internal/command"
"github.com/qdm12/gluetun/internal/configuration/settings"
"github.com/qdm12/gluetun/internal/models"
"github.com/qdm12/gluetun/internal/netlink"
@@ -120,3 +121,7 @@ type Service interface {
Start() (runError <-chan error, err error)
Stop() error
}
type Cmder interface {
RunAndLog(ctx context.Context, command string, logger command.Logger) (err error)
}
+1
View File
@@ -17,6 +17,7 @@ type Loop struct {
state *state.State
providers Providers
storage Storage
cmder Cmder
healthSettings settings.Health
healthChecker HealthChecker
healthServer HealthServer
+1
View File
@@ -47,6 +47,7 @@ func (l *Loop) Run(ctx context.Context, done chan<- struct{}) {
continue
}
tunnelUpData := tunnelUpData{
upCommand: *settings.UpCommand,
pmtud: tunnelUpPMTUDData{
enabled: settings.Type != vpn.Wireguard || *settings.Wireguard.MTU == 0,
vpnType: settings.Type,
+10
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/netip"
"strings"
"time"
"github.com/qdm12/gluetun/internal/constants"
@@ -16,6 +17,7 @@ import (
)
type tunnelUpData struct {
upCommand string
// Healthcheck
serverIP netip.Addr
pmtud tunnelUpPMTUDData
@@ -107,6 +109,14 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
}
}
if data.upCommand != "" {
commandString := strings.ReplaceAll(data.upCommand, "{{VPN_INTERFACE}}", data.vpnIntf)
err := l.cmder.RunAndLog(context.Background(), commandString, l.logger)
if err != nil {
l.logger.Error("failed to run VPN up command: " + err.Error())
}
}
err = l.startPortForwarding(data)
if err != nil {
l.logger.Error(err.Error())