diff --git a/Dockerfile b/Dockerfile index a666f672..9d34317a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,6 +110,9 @@ ENV VPNSP=pia \ # Openvpn OPENVPN_CIPHER= \ OPENVPN_AUTH= \ + # Health + HEALTH_OPENVPN_DURATION_INITIAL=6s \ + HEALTH_OPENVPN_DURATION_ADDITION=5s \ # DNS over TLS DOT=on \ DOT_PROVIDERS=cloudflare \ diff --git a/cmd/gluetun/main.go b/cmd/gluetun/main.go index cddcb861..4e808d47 100644 --- a/cmd/gluetun/main.go +++ b/cmd/gluetun/main.go @@ -365,8 +365,8 @@ func _main(ctx context.Context, buildInfo models.BuildInformation, controlGroupHandler.Add(httpServerHandler) healthLogger := logger.NewChild(logging.Settings{Prefix: "healthcheck: "}) - healthcheckServer := healthcheck.NewServer( - constants.HealthcheckAddress, healthLogger, openvpnLooper) + healthcheckServer := healthcheck.NewServer(constants.HealthcheckAddress, + allSettings.Health, healthLogger, openvpnLooper) healthServerHandler, healthServerCtx, healthServerDone := goshutdown.NewGoRoutineHandler( "HTTP health server", defaultGoRoutineSettings) go healthcheckServer.Run(healthServerCtx, healthServerDone) diff --git a/internal/configuration/health.go b/internal/configuration/health.go new file mode 100644 index 00000000..409d6610 --- /dev/null +++ b/internal/configuration/health.go @@ -0,0 +1,41 @@ +package configuration + +import ( + "strings" + + "github.com/qdm12/golibs/params" +) + +// Health contains settings for the healthcheck and health server. +type Health struct { + OpenVPN HealthyWait +} + +func (settings *Health) String() string { + return strings.Join(settings.lines(), "\n") +} + +func (settings *Health) lines() (lines []string) { + lines = append(lines, lastIndent+"Health:") + + lines = append(lines, indent+lastIndent+"OpenVPN:") + for _, line := range settings.OpenVPN.lines() { + lines = append(lines, indent+indent+line) + } + + return lines +} + +func (settings *Health) read(r reader) (err error) { + settings.OpenVPN.Initial, err = r.env.Duration("HEALTH_OPENVPN_DURATION_INITIAL", params.Default("6s")) + if err != nil { + return err + } + + settings.OpenVPN.Addition, err = r.env.Duration("HEALTH_OPENVPN_DURATION_ADDITION", params.Default("5s")) + if err != nil { + return err + } + + return nil +} diff --git a/internal/configuration/health_test.go b/internal/configuration/health_test.go new file mode 100644 index 00000000..aa99994d --- /dev/null +++ b/internal/configuration/health_test.go @@ -0,0 +1,150 @@ +package configuration + +import ( + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/qdm12/golibs/params/mock_params" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Health_String(t *testing.T) { + t.Parallel() + + var health Health + const expected = "|--Health:\n |--OpenVPN:\n |--Initial duration: 0s" + + s := health.String() + + assert.Equal(t, expected, s) +} + +func Test_Health_lines(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + settings Health + lines []string + }{ + "empty": { + lines: []string{ + "|--Health:", + " |--OpenVPN:", + " |--Initial duration: 0s", + }, + }, + "filled settings": { + settings: Health{ + OpenVPN: HealthyWait{ + Initial: time.Second, + Addition: time.Minute, + }, + }, + lines: []string{ + "|--Health:", + " |--OpenVPN:", + " |--Initial duration: 1s", + " |--Addition duration: 1m0s", + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + lines := testCase.settings.lines() + + assert.Equal(t, testCase.lines, lines) + }) + } +} + +func Test_Health_read(t *testing.T) { + t.Parallel() + + errDummy := errors.New("dummy") + + testCases := map[string]struct { + openvpnInitialDuration time.Duration + openvpnInitialErr error + openvpnAdditionDuration time.Duration + openvpnAdditionErr error + expected Health + err error + }{ + "success": { + openvpnInitialDuration: time.Second, + openvpnAdditionDuration: time.Minute, + expected: Health{ + OpenVPN: HealthyWait{ + Initial: time.Second, + Addition: time.Minute, + }, + }, + }, + "initial error": { + openvpnInitialDuration: time.Second, + openvpnInitialErr: errDummy, + openvpnAdditionDuration: time.Minute, + expected: Health{ + OpenVPN: HealthyWait{ + Initial: time.Second, + }, + }, + err: errDummy, + }, + "addition error": { + openvpnInitialDuration: time.Second, + openvpnAdditionDuration: time.Minute, + openvpnAdditionErr: errDummy, + expected: Health{ + OpenVPN: HealthyWait{ + Initial: time.Second, + Addition: time.Minute, + }, + }, + err: errDummy, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + env := mock_params.NewMockEnv(ctrl) + env.EXPECT(). + Duration("HEALTH_OPENVPN_DURATION_INITIAL", gomock.Any()). + Return(testCase.openvpnInitialDuration, testCase.openvpnInitialErr) + if testCase.openvpnInitialErr == nil { + env.EXPECT(). + Duration("HEALTH_OPENVPN_DURATION_ADDITION", gomock.Any()). + Return(testCase.openvpnAdditionDuration, testCase.openvpnAdditionErr) + } + + r := reader{ + env: env, + } + + var health Health + + err := health.read(r) + + if testCase.err != nil { + require.Error(t, err) + assert.Equal(t, testCase.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, testCase.expected, health) + }) + } +} diff --git a/internal/configuration/healthwait_test.go b/internal/configuration/healthwait_test.go new file mode 100644 index 00000000..7abe619b --- /dev/null +++ b/internal/configuration/healthwait_test.go @@ -0,0 +1,55 @@ +package configuration + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_HealthyWait_String(t *testing.T) { + t.Parallel() + + var healthyWait HealthyWait + const expected = "|--Initial duration: 0s" + + s := healthyWait.String() + + assert.Equal(t, expected, s) +} + +func Test_HealthyWait_lines(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + settings HealthyWait + lines []string + }{ + "empty": { + lines: []string{ + "|--Initial duration: 0s", + }, + }, + "filled settings": { + settings: HealthyWait{ + Initial: time.Second, + Addition: time.Minute, + }, + lines: []string{ + "|--Initial duration: 1s", + "|--Addition duration: 1m0s", + }, + }, + } + + for name, testCase := range testCases { + testCase := testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + lines := testCase.settings.lines() + + assert.Equal(t, testCase.lines, lines) + }) + } +} diff --git a/internal/configuration/healthywait.go b/internal/configuration/healthywait.go new file mode 100644 index 00000000..ee85940d --- /dev/null +++ b/internal/configuration/healthywait.go @@ -0,0 +1,30 @@ +package configuration + +import ( + "strings" + "time" +) + +type HealthyWait struct { + // Initial is the initial duration to wait for the program + // to be healthy before taking action. + Initial time.Duration + // Addition is the duration to add to the Initial duration + // after Initial has expired to wait longer for the program + // to be healthy. + Addition time.Duration +} + +func (settings *HealthyWait) String() string { + return strings.Join(settings.lines(), "\n") +} + +func (settings *HealthyWait) lines() (lines []string) { + lines = append(lines, lastIndent+"Initial duration: "+settings.Initial.String()) + + if settings.Addition > 0 { + lines = append(lines, lastIndent+"Addition duration: "+settings.Addition.String()) + } + + return lines +} diff --git a/internal/configuration/settings.go b/internal/configuration/settings.go index 21ccaede..b9a13dfc 100644 --- a/internal/configuration/settings.go +++ b/internal/configuration/settings.go @@ -22,6 +22,7 @@ type Settings struct { PublicIP PublicIP VersionInformation bool ControlServer ControlServer + Health Health } func (settings *Settings) String() string { @@ -36,6 +37,7 @@ func (settings *Settings) lines() (lines []string) { lines = append(lines, settings.System.lines()...) lines = append(lines, settings.HTTPProxy.lines()...) lines = append(lines, settings.ShadowSocks.lines()...) + lines = append(lines, settings.Health.lines()...) lines = append(lines, settings.ControlServer.lines()...) lines = append(lines, settings.Updater.lines()...) lines = append(lines, settings.PublicIP.lines()...) @@ -55,6 +57,7 @@ var ( ErrControlServer = errors.New("cannot read control server settings") ErrUpdater = errors.New("cannot read Updater settings") ErrPublicIP = errors.New("cannot read Public IP getter settings") + ErrHealth = errors.New("cannot read health settings") ) // Read obtains all configuration options for the program and returns an error as soon @@ -107,5 +110,9 @@ func (settings *Settings) Read(env params.Env, os os.OS, logger logging.Logger) return fmt.Errorf("%w: %s", ErrPublicIP, err) } + if err := settings.Health.read(r); err != nil { + return fmt.Errorf("%w: %s", ErrHealth, err) + } + return nil } diff --git a/internal/configuration/settings_test.go b/internal/configuration/settings_test.go index a9eee96b..c51b1c78 100644 --- a/internal/configuration/settings_test.go +++ b/internal/configuration/settings_test.go @@ -37,6 +37,9 @@ func Test_Settings_lines(t *testing.T) { " |--Process user ID: 0", " |--Process group ID: 0", " |--Timezone: NOT SET ⚠️ - it can cause time related issues", + "|--Health:", + " |--OpenVPN:", + " |--Initial duration: 0s", "|--HTTP control server:", " |--Listening port: 0", "|--Public IP getter: disabled", diff --git a/internal/healthcheck/health.go b/internal/healthcheck/health.go index 371f1bf6..b0fb9295 100644 --- a/internal/healthcheck/health.go +++ b/internal/healthcheck/health.go @@ -12,7 +12,7 @@ import ( func (s *server) runHealthcheckLoop(ctx context.Context, done chan<- struct{}) { defer close(done) - s.openvpn.healthyTimer = time.NewTimer(defaultOpenvpnHealthyWaitTime) + s.openvpn.healthyTimer = time.NewTimer(s.openvpn.currentHealthyWait) for { previousErr := s.handler.getErr() @@ -23,10 +23,10 @@ func (s *server) runHealthcheckLoop(ctx context.Context, done chan<- struct{}) { if previousErr != nil && err == nil { s.logger.Info("healthy!") s.openvpn.healthyTimer.Stop() - s.openvpn.healthyWaitTime = defaultOpenvpnHealthyWaitTime + s.openvpn.currentHealthyWait = s.openvpn.healthyWaitConfig.Initial } else if previousErr == nil && err != nil { s.logger.Info("unhealthy: " + err.Error()) - s.openvpn.healthyTimer = time.NewTimer(s.openvpn.healthyWaitTime) + s.openvpn.healthyTimer = time.NewTimer(s.openvpn.currentHealthyWait) } if err != nil { // try again after 1 second diff --git a/internal/healthcheck/openvpn.go b/internal/healthcheck/openvpn.go index 4c53991c..dcf6a818 100644 --- a/internal/healthcheck/openvpn.go +++ b/internal/healthcheck/openvpn.go @@ -9,9 +9,9 @@ import ( func (s *server) onUnhealthyOpenvpn(ctx context.Context) { s.logger.Info("program has been unhealthy for " + - s.openvpn.healthyWaitTime.String() + ": restarting OpenVPN") + s.openvpn.currentHealthyWait.String() + ": restarting OpenVPN") _, _ = s.openvpn.looper.ApplyStatus(ctx, constants.Stopped) _, _ = s.openvpn.looper.ApplyStatus(ctx, constants.Running) - s.openvpn.healthyWaitTime += openvpnHealthyWaitTimeAdd - s.openvpn.healthyTimer = time.NewTimer(s.openvpn.healthyWaitTime) + s.openvpn.currentHealthyWait += s.openvpn.healthyWaitConfig.Addition + s.openvpn.healthyTimer = time.NewTimer(s.openvpn.currentHealthyWait) } diff --git a/internal/healthcheck/server.go b/internal/healthcheck/server.go index 2ffff079..5a3c7600 100644 --- a/internal/healthcheck/server.go +++ b/internal/healthcheck/server.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "github.com/qdm12/gluetun/internal/configuration" "github.com/qdm12/gluetun/internal/openvpn" "github.com/qdm12/golibs/logging" ) @@ -24,26 +25,23 @@ type server struct { } type openvpnHealth struct { - looper openvpn.Looper - healthyWaitTime time.Duration - healthyTimer *time.Timer + looper openvpn.Looper + healthyWaitConfig configuration.HealthyWait + currentHealthyWait time.Duration + healthyTimer *time.Timer } -const ( - defaultOpenvpnHealthyWaitTime = 6 * time.Second - openvpnHealthyWaitTimeAdd = 5 * time.Second -) - -func NewServer(address string, logger logging.Logger, - openvpnLooper openvpn.Looper) Server { +func NewServer(address string, settings configuration.Health, + logger logging.Logger, openvpnLooper openvpn.Looper) Server { return &server{ address: address, logger: logger, handler: newHandler(logger), resolver: net.DefaultResolver, openvpn: openvpnHealth{ - looper: openvpnLooper, - healthyWaitTime: defaultOpenvpnHealthyWaitTime, + looper: openvpnLooper, + currentHealthyWait: settings.OpenVPN.Initial, + healthyWaitConfig: settings.OpenVPN, }, } }