Repository: jkroepke/openvpn-auth-oauth2
Pull request #104 (2024-01-03 12:12:35)

Terminate, if OpenVPN management connection dies.
#### What this PR does / why we need it

#### Which issue this PR fixes

*(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close that issue when PR gets merged)*: fixes #

- fixes #

#### Special notes for your reviewer

#### Particularly user-facing changes

#### Checklist

Complete these before marking the PR as `ready to review`:

<!-- [Place an '[x]' (no spaces) in all applicable fields.] -->

- [ ] [DCO](https://github.com/prometheus-community/helm-charts/blob/main/CONTRIBUTING.md#sign-off-your-work) signed
- [ ] The PR title has a summary of the changes
- [ ] The PR body has a summary to reflect any significant (and particularly user-facing) changes introduced by this PR


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) Report
Attention: `27 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`59b093b`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/commit/59b093b3e7e4062ade019d3bed7db1c05eff7b7e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 76.41% compared to head [(`2339284`)](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) 75.62%.
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) | Patch % | Lines |
|---|---|---|
| [cmd/root.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-Y21kL3Jvb3QuZ28=) | 7.40% | [24 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/openvpn/handler.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9oYW5kbGVyLmdv) | 85.71% | [1 Missing :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |
| [internal/openvpn/main.go](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke#diff-aW50ZXJuYWwvb3BlbnZwbi9tYWluLmdv) | 93.75% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #104      +/-   ##
==========================================
- Coverage   76.41%   75.62%   -0.79%     
==========================================
  Files          39       39              
  Lines        2039     2084      +45     
==========================================
+ Hits         1558     1576      +18     
- Misses        407      433      +26     
- Partials       74       75       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/jkroepke/openvpn-auth-oauth2/pull/104?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jan-Otto+Kr%C3%B6pke).


Patch:
diff --git a/.gitignore b/.gitignore
index 6722d09..93b3a91 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,10 @@ tests/*
 !tests/docker-compose.yaml
 !tests/Dockerfile
 !tests/.dockerignore
+!tests/systemd/
+tests/systemd/openvpn-auth-oauth2.conf
 
-/openvpn-auth-azure-ad
+/openvpn-auth-azure-adss
 dist/
 
 ### macOS template
diff --git a/cmd/root.go b/cmd/root.go
index 5839ace..a5ad5fd 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -6,13 +6,16 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"net/http"
+	"net/http/pprof"
 	"os"
 	"os/signal"
 	"runtime"
 	"syscall"
+	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/http"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/httpserver"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
@@ -68,10 +71,39 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 		return 1
 	}
 
-	server := http.NewHTTPServer(logger, conf, oauth2Client.Handler())
+	server := httpserver.NewHTTPServer(logger, conf, oauth2Client.Handler())
 
 	done := make(chan int, 1)
 
+	if conf.Debug.Pprof {
+		go func() {
+			logger.Warn(fmt.Sprintf("start HTTP debug server on %s", conf.Debug.Listen))
+
+			mux := http.NewServeMux()
+			mux.Handle("/", http.RedirectHandler("/debug/pprof/", http.StatusTemporaryRedirect))
+			mux.HandleFunc("/debug/pprof/", pprof.Index)
+			mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
+			mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
+			mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
+			mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
+
+			server := &http.Server{
+				Addr:              conf.Debug.Listen,
+				ReadHeaderTimeout: 3 * time.Second,
+				Handler:           mux,
+			}
+
+			if err := server.ListenAndServe(); err != nil {
+				logger.Error(fmt.Errorf("error http debug listener: %w", err).Error())
+				done <- 1
+
+				return
+			}
+
+			done <- 0
+		}()
+	}
+
 	go func() {
 		if err := server.Listen(); err != nil {
 			logger.Error(fmt.Errorf("error http listener: %w", err).Error())
@@ -109,7 +141,7 @@ func Execute(args []string, logWriter io.Writer, version, commit, date string) i
 	return returnCode
 }
 
-func shutdown(logger *slog.Logger, openvpnClient *openvpn.Client, server http.Server) {
+func shutdown(logger *slog.Logger, openvpnClient *openvpn.Client, server httpserver.Server) {
 	openvpnClient.Shutdown()
 
 	logger.Info("start graceful shutdown of http listener")
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 6aef451..70234cd 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -3,13 +3,35 @@
 The preferred way to configure openvpn-auth-oauth2 is via environment variables. If you install the openvpn-auth-auth2 via
 Linux package, use the file `/etc/sysconfig/openvpn-auth-oauth2` to configure openvpn-auth-oauth2.
 
+## Full configuration example
+
+Configuration openvpn-auth-oauth2 for [zitadel](https://zitadel.com/)
+
+```ini
+# Define the public http endpoint here.
+CONFIG_HTTP_BASEURL=http://<vpn>:9000/
+CONFIG_HTTP_LISTEN=:9000
+# Define a random value with 16 or 24 characters
+CONFIG_HTTP_SECRET=1jd93h5b6s82lf03jh5b2hf9
+CONFIG_OPENVPN_ADDR=unix:///run/openvpn/server.sock
+CONFIG_OPENVPN_PASSWORD=<password from /etc/openvpn/password.txt>
+CONFIG_OAUTH2_ISSUER=https://company.zitadel.cloud
+CONFIG_OAUTH2_SCOPES=openid profile email offline_access
+CONFIG_OAUTH2_CLIENT_ID=34372461928374612@any
+CONFIG_OAUTH2_CLIENT_SECRET=ASDhjgadjhAUYSDGjkhasgdIATWDGJHASDtiwGDJAHSGDutwqdygASJKD12hfva
+```
+
 ## Supported configuration properties
 
 ```
-Usage of /var/folders/cs/zz5gz_v567v7y00jpvc5v16h0000gn/T/go-build2152924040/b001/exe/main:
+Usage of ./openvpn-auth-oauth2:
 
   --config string
     	path to one .yaml config file (env: CONFIG_CONFIG)
+  --debug.listen string
+    	listen address for go profiling endpoint (env: CONFIG_DEBUG_LISTEN) (default ":9001")
+  --debug.pprof
+    	Enables go profiling endpoint. This should be never exposed. (env: CONFIG_DEBUG_PPROF)
   --http.baseurl string
     	listen addr for client listener (env: CONFIG_HTTP_BASEURL) (default "http://localhost:9000")
   --http.cert string
@@ -111,8 +133,10 @@ To connect openvpn-auth-oauth2 with openvpn server, add lines below:
 ...
 # /etc/openvpn/password.txt is a password file where the password must be on first line
 management /run/openvpn/server.sock unix /etc/openvpn/password.txt
-management-hold
 management-client-auth
+# management-hold holds the OpenVPN server until openvpn-auth-oauth2 has been connected.
+# In some situation, there is a deadlock where systemd waits for OpenVPN server, not starting
+#management-hold
 ```
 
 ```ini
@@ -125,24 +149,6 @@ CONFIG_OPENVPN_PASSWORD=<password>
 
 See [Providers](Providers) for more information
 
-## Full configuration example
-
-Configuration openvpn-auth-oauth2 for [zitadel](https://zitadel.com/)
-
-```ini
-# Define the public http endpoint here.
-CONFIG_HTTP_BASEURL=http://<vpn>:9000/
-CONFIG_HTTP_LISTEN=:9000
-# Define a random value with 16 or 24 characters
-CONFIG_HTTP_SECRET=1jd93h5b6s82lf03jh5b2hf9
-CONFIG_OPENVPN_ADDR=unix:///run/openvpn/server.sock
-CONFIG_OPENVPN_PASSWORD=<password from /etc/openvpn/password.txt>
-CONFIG_OAUTH2_ISSUER=https://company.zitadel.cloud
-CONFIG_OAUTH2_SCOPES=openid profile email offline_access
-CONFIG_OAUTH2_CLIENT_ID=34372461928374612@any
-CONFIG_OAUTH2_CLIENT_SECRET=ASDhjgadjhAUYSDGjkhasgdIATWDGJHASDtiwGDJAHSGDutwqdygASJKD12hfva
-```
-
 ## HTTPS Listener
 
 > [!IMPORTANT]
diff --git a/docs/_Footer.md b/docs/_Footer.md
index 14f3907..2e5ed57 100644
--- a/docs/_Footer.md
+++ b/docs/_Footer.md
@@ -1,2 +1,1 @@
-This wiki is synced with the [`docs`](https://github.com/jkroepke/openvpn-auth-oauth2/tree/main/docs) folder from the code repository! To improve the
-wiki, create a [pull request](https://github.com/jkroepke/openvpn-auth-oauth2/pulls) against the code repository with the suggested changes.
+This wiki is synced with the [`docs`](https://github.com/jkroepke/openvpn-auth-oauth2/tree/main/docs) folder from the code repository! To improve the wiki, create a [pull request](https://github.com/jkroepke/openvpn-auth-oauth2/pulls) against the code repository with the suggested changes.
diff --git a/go.mod b/go.mod
index eb8e0ec..0b1ee0b 100644
--- a/go.mod
+++ b/go.mod
@@ -46,7 +46,7 @@ require (
 	golang.org/x/crypto v0.17.0 // indirect
 	golang.org/x/sys v0.15.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/protobuf v1.31.0 // indirect
+	google.golang.org/protobuf v1.32.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
diff --git a/go.sum b/go.sum
index 98d0c5e..065bc34 100644
--- a/go.sum
+++ b/go.sum
@@ -92,8 +92,6 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
-golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -133,8 +131,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
 google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
+google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/internal/config/config.go b/internal/config/config.go
index fec7d90..b8ed021 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -32,6 +32,16 @@ func FlagSet(name string) *flag.FlagSet {
 		"",
 		"path to one .yaml config file",
 	)
+	flagSet.Bool(
+		"debug.pprof",
+		Defaults.Debug.Pprof,
+		"Enables go profiling endpoint. This should be never exposed.",
+	)
+	flagSet.String(
+		"debug.listen",
+		Defaults.Debug.Listen,
+		"listen address for go profiling endpoint",
+	)
 	flagSet.String(
 		"log.format",
 		Defaults.Log.Format,
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
index a8196a4..9a50666 100644
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -11,6 +11,9 @@ import (
 
 //nolint:gochecknoglobals
 var Defaults = Config{
+	Debug: Debug{
+		Listen: ":9001",
+	},
 	Log: Log{
 		Format: "console",
 		Level:  slog.LevelInfo,
diff --git a/internal/config/types.go b/internal/config/types.go
index 8461134..fd4d24a 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -11,6 +11,7 @@ import (
 
 type Config struct {
 	ConfigFile string  `koanf:"config"`
+	Debug      Debug   `koanf:"debug"`
 	Log        Log     `koanf:"log"`
 	HTTP       HTTP    `koanf:"http"`
 	OpenVpn    OpenVpn `koanf:"openvpn"`
@@ -93,6 +94,11 @@ type OAuth2Refresh struct {
 	Secret  Secret        `koanf:"secret"`
 }
 
+type Debug struct {
+	Pprof  bool   `koanf:"pprof"`
+	Listen string `koanf:"listen"`
+}
+
 type OpenVPNCommonNameMode int
 
 const (
diff --git a/internal/http/main.go b/internal/httpserver/main.go
similarity index 98%
rename from internal/http/main.go
rename to internal/httpserver/main.go
index 10077be..72c40d1 100644
--- a/internal/http/main.go
+++ b/internal/httpserver/main.go
@@ -1,4 +1,4 @@
-package http
+package httpserver
 
 import (
 	"context"
diff --git a/internal/openvpn/errors.go b/internal/openvpn/errors.go
index b220c1b..904b540 100644
--- a/internal/openvpn/errors.go
+++ b/internal/openvpn/errors.go
@@ -5,9 +5,10 @@ import (
 )
 
 var (
-	ErrTimeout         = errors.New("timeout")
-	ErrEmptyResponse   = errors.New("empty response")
-	ErrUnknownProtocol = errors.New("unknown protocol")
-	ErrInvalidPassword = errors.New("invalid password")
-	ErrErrorResponse   = errors.New("error response")
+	ErrTimeout              = errors.New("timeout")
+	ErrEmptyResponse        = errors.New("empty response")
+	ErrUnknownProtocol      = errors.New("unknown protocol")
+	ErrInvalidPassword      = errors.New("invalid password")
+	ErrErrorResponse        = errors.New("error response")
+	ErrConnectionTerminated = errors.New("OpenVPN management interface connection terminated")
 )
diff --git a/internal/openvpn/handler.go b/internal/openvpn/handler.go
index d2e50f0..1be1b80 100644
--- a/internal/openvpn/handler.go
+++ b/internal/openvpn/handler.go
@@ -12,7 +12,7 @@ import (
 
 // handlePassword enters the password on the OpenVPN management interface connection.
 func (c *Client) handlePassword() error {
-	buf := make([]byte, 5)
+	buf := make([]byte, 15)
 
 	_, err := c.conn.Read(buf)
 	if err != nil {
@@ -21,7 +21,8 @@ func (c *Client) handlePassword() error {
 
 	c.logger.Debug(utils.StringConcat("password probe: ", string(buf)))
 
-	if string(buf) == "ENTER" {
+	switch {
+	case string(buf) == "ENTER PASSWORD:":
 		if c.conf.OpenVpn.Password == "" {
 			return errors.New("management password required")
 		}
@@ -29,8 +30,11 @@ func (c *Client) handlePassword() error {
 		if err = c.sendPassword(); err != nil {
 			return err
 		}
-	} else if c.conf.OpenVpn.Password != "" {
+	case c.conf.OpenVpn.Password != "":
 		return errors.New("management password expected, but server does not ask for me")
+	default:
+		// In case there is no password, read the whole line
+		c.scanner.Scan()
 	}
 
 	return nil
diff --git a/internal/openvpn/main.go b/internal/openvpn/main.go
index e89a737..2c4092b 100644
--- a/internal/openvpn/main.go
+++ b/internal/openvpn/main.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"log/slog"
 	"net"
 	"strconv"
@@ -21,6 +22,9 @@ import (
 )
 
 func NewClient(logger *slog.Logger, conf config.Config, oauth2Client *oauth2.Provider) *Client {
+	commandsBuffer := bytes.Buffer{}
+	commandsBuffer.Grow(512)
+
 	return &Client{
 		conf:   conf,
 		logger: logger,
@@ -29,6 +33,8 @@ func NewClient(logger *slog.Logger, conf config.Config, oauth2Client *oauth2.Pro
 		closed: false,
 		mu:     sync.Mutex{},
 
+		commandsBuffer: commandsBuffer,
+
 		errCh:             make(chan error, 1),
 		clientsCh:         make(chan connection.Client, 10),
 		commandResponseCh: make(chan string, 10),
@@ -65,7 +71,7 @@ func (c *Client) Connect() error {
 		return err
 	}
 
-	c.logger.Info("connection to OpenVPN management interfaced established.")
+	c.logger.Info("connection to OpenVPN management interface established.")
 
 	err = c.checkManagementInterfaceVersion()
 	if err != nil {
@@ -75,6 +81,10 @@ func (c *Client) Connect() error {
 	for {
 		select {
 		case err := <-c.errCh:
+			if errors.Is(err, io.EOF) {
+				return ErrConnectionTerminated
+			}
+
 			c.close()
 
 			if err != nil {
@@ -141,6 +151,8 @@ func (c *Client) checkManagementInterfaceVersion() error {
 		return fmt.Errorf("unable to parse openvpn management interface version: %w", err)
 	}
 
+	// Management Interface Version 5 is required at minimum
+	// ref: https://github.com/OpenVPN/openvpn/commit/a261e173341f8e68505a6ab5a413d09b0797a459
 	if managementInterfaceVersion < 5 {
 		return errors.New("openvpn-auth-oauth2 requires OpenVPN management interface version 5 or higher")
 	}
@@ -207,11 +219,11 @@ func (c *Client) rawCommand(cmd string) error {
 		c.logger.Debug(cmd)
 	}
 
-	if _, err := c.conn.Write([]byte(cmd)); err != nil {
-		return fmt.Errorf("unable to write into OpenVPN management connection: %w", err)
-	}
+	c.commandsBuffer.Reset()
+	c.commandsBuffer.WriteString(cmd)
+	c.commandsBuffer.WriteString("\n")
 
-	if _, err := c.conn.Write([]byte("\n")); err != nil {
+	if _, err := c.commandsBuffer.WriteTo(c.conn); err != nil {
 		return fmt.Errorf("unable to write into OpenVPN management connection: %w", err)
 	}
 
@@ -242,18 +254,17 @@ func (c *Client) readMessage(buf *bytes.Buffer) error {
 		return fmt.Errorf("scanner error: %w", c.scanner.Err())
 	}
 
-	return nil
+	return io.EOF
 }
 
 func (c *Client) isMessageLineEOF(line []byte) bool {
 	return bytes.HasPrefix(line, []byte(">CLIENT:ENV,END")) ||
-		bytes.Index(line, []byte("END")) == 0 ||
 		bytes.HasPrefix(line, []byte("SUCCESS:")) ||
 		bytes.HasPrefix(line, []byte("ERROR:")) ||
+		bytes.HasPrefix(line, []byte("END")) ||
 		bytes.HasPrefix(line, []byte(">HOLD:")) ||
 		bytes.HasPrefix(line, []byte(">INFO:")) ||
-		bytes.HasPrefix(line, []byte(">NOTIFY:")) ||
-		bytes.HasPrefix(line, []byte(":OpenVPN"))
+		bytes.HasPrefix(line, []byte(">NOTIFY:"))
 }
 
 func (c *Client) close() {
@@ -263,7 +274,6 @@ func (c *Client) close() {
 	if !c.closed {
 		c.closed = true
 
-		_ = c.rawCommand("quit")
 		_ = c.conn.Close()
 		close(c.commandsCh)
 	}
diff --git a/internal/openvpn/types.go b/internal/openvpn/types.go
index 5063071..589d18a 100644
--- a/internal/openvpn/types.go
+++ b/internal/openvpn/types.go
@@ -2,6 +2,7 @@ package openvpn
 
 import (
 	"bufio"
+	"bytes"
 	"log/slog"
 	"net"
 	"sync"
@@ -21,6 +22,8 @@ type Client struct {
 	mu     sync.Mutex
 	closed bool
 
+	commandsBuffer bytes.Buffer
+
 	clientsCh         chan connection.Client
 	commandResponseCh chan string
 	commandsCh        chan string
diff --git a/packaging/openvpn-auth-oauth2.service b/packaging/openvpn-auth-oauth2.service
index cfbc6a7..31370ba 100644
--- a/packaging/openvpn-auth-oauth2.service
+++ b/packaging/openvpn-auth-oauth2.service
@@ -2,7 +2,8 @@
 Description=OpenVPN authenticator
 Documentation=https://github.com/jkroepke/openvpn-auth-oauth2
 Wants=network-online.target openvpn.service
-After=network-online.target openvpn.service
+After=network-online.target openvpn.service openvpn@.service openvpn-server@.service
+PartOf=openvpn.service openvpn@.service openvpn-server@.service
 
 [Service]
 DynamicUser=true
diff --git a/pkg/plugin/go.mod b/pkg/plugin/go.mod
index b8715f8..a716498 100644
--- a/pkg/plugin/go.mod
+++ b/pkg/plugin/go.mod
@@ -27,15 +27,15 @@ require (
 	github.com/muhlemmer/gu v0.3.1 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/zitadel/logging v0.5.0 // indirect
-	github.com/zitadel/oidc/v3 v3.8.0 // indirect
+	github.com/zitadel/oidc/v3 v3.8.1 // indirect
 	github.com/zitadel/schema v1.3.0 // indirect
 	golang.org/x/crypto v0.17.0 // indirect
-	golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 // indirect
+	golang.org/x/exp v0.0.0-20231226003508-02704c960a9b // indirect
 	golang.org/x/oauth2 v0.15.0 // indirect
 	golang.org/x/sys v0.15.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/protobuf v1.31.0 // indirect
+	google.golang.org/protobuf v1.32.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/pkg/plugin/go.sum b/pkg/plugin/go.sum
index fb96248..a1c93a4 100644
--- a/pkg/plugin/go.sum
+++ b/pkg/plugin/go.sum
@@ -9,8 +9,7 @@ github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
 github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
 github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
-github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
-github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -75,8 +74,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/zitadel/logging v0.5.0 h1:Kunouvqse/efXy4UDvFw5s3vP+Z4AlHo3y8wF7stXHA=
 github.com/zitadel/logging v0.5.0/go.mod h1:IzP5fzwFhzzyxHkSmfF8dsyqFsQRJLLcQmwhIBzlGsE=
-github.com/zitadel/oidc/v3 v3.8.0 h1:4Nvok+e6o3FDpqrf14JOg4EVBvwXNFOI1lFHPZU75iA=
-github.com/zitadel/oidc/v3 v3.8.0/go.mod h1:v+aHyg4lBAUuuUHINwXqHtKunPJZo8kPvMpRRBYEKHY=
+github.com/zitadel/oidc/v3 v3.8.1 h1:YsFWUpT3JFsDlF9ePwM851CymDwqfQ3UU1CoOEOMEdU=
+github.com/zitadel/oidc/v3 v3.8.1/go.mod h1:rUKTJBsamKtqurN1MpuRYxF5FgW/9RJ/1/AF3g7/2k0=
 github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
 github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
 go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
@@ -90,8 +89,7 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
-golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
-golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
+golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -129,8 +127,7 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
 google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
-google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/pkg/plugin/main.go b/pkg/plugin/main.go
index ef5ad31..7e1c3fc 100644
--- a/pkg/plugin/main.go
+++ b/pkg/plugin/main.go
@@ -8,7 +8,7 @@ import (
 	"log/slog"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/http"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/httpserver"
 )
 
 //nolint:gochecknoglobals
@@ -22,7 +22,7 @@ var (
 type PluginHandle struct {
 	logger *slog.Logger
 	conf   config.Config
-	server http.Server
+	server httpserver.Server
 }
 
 func main() {
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index a4b9950..8871cce 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -17,7 +17,7 @@ import (
 	"unsafe"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/http"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/httpserver"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/oauth2"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/storage"
@@ -84,7 +84,7 @@ func openvpn_plugin_open_v3_go(v3structver C.int, args *C.struct_openvpn_plugin_
 		return C.OPENVPN_PLUGIN_FUNC_ERROR
 	}
 
-	handle.server = http.NewHTTPServer(logger, conf, provider.Handler())
+	handle.server = httpserver.NewHTTPServer(logger, conf, provider.Handler())
 	go func() {
 		if err := handle.server.Listen(); err != nil {
 			logger.Error(fmt.Errorf("error http listener: %w", err).Error())


Test patch:
diff --git a/internal/http/main_test.go b/internal/httpserver/main_test.go
similarity index 92%
rename from internal/http/main_test.go
rename to internal/httpserver/main_test.go
index 6505b52..302f1d9 100644
--- a/internal/http/main_test.go
+++ b/internal/httpserver/main_test.go
@@ -1,4 +1,4 @@
-package http_test
+package httpserver_test
 
 import (
 	"errors"
@@ -10,7 +10,7 @@ import (
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
-	"github.com/jkroepke/openvpn-auth-oauth2/internal/http"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/httpserver"
 	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/madflojo/testcerts"
 	"github.com/stretchr/testify/assert"
@@ -80,7 +80,7 @@ func TestNewHTTPServer(t *testing.T) {
 			mux := gohttp.NewServeMux()
 			mux.Handle("/", gohttp.NotFoundHandler())
 
-			svr := http.NewHTTPServer(logger, tt.conf, mux)
+			svr := httpserver.NewHTTPServer(logger, tt.conf, mux)
 
 			wg := sync.WaitGroup{}
 			wg.Add(1)
diff --git a/internal/oauth2/handler_test.go b/internal/oauth2/handler_test.go
index 5bc9b64..09f3b78 100644
--- a/internal/oauth2/handler_test.go
+++ b/internal/oauth2/handler_test.go
@@ -3,16 +3,17 @@ package oauth2_test
 import (
 	"bufio"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
-	"strings"
 	"sync"
 	"testing"
 	"text/template"
 	"time"
 
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/config"
+	"github.com/jkroepke/openvpn-auth-oauth2/internal/openvpn"
 	"github.com/jkroepke/openvpn-auth-oauth2/internal/state"
 	"github.com/jkroepke/openvpn-auth-oauth2/pkg/testutils"
 	"github.com/stretchr/testify/assert"
@@ -323,7 +324,7 @@ func TestHandler(t *testing.T) {
 			go func() {
 				defer wg.Done()
 				err := client.Connect()
-				if err != nil && !strings.HasSuffix(err.Error(), "EOF") {
+				if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, openvpn.ErrConnectionTerminated) {
 					require.NoError(t, err)
 				}
 			}()
diff --git a/internal/openvpn/main_test.go b/internal/openvpn/main_test.go
index 604c1df..f3f8624 100644
--- a/internal/openvpn/main_test.go
+++ b/internal/openvpn/main_test.go
@@ -267,7 +267,7 @@ func TestClientFull(t *testing.T) {
 				client.Shutdown()
 			} else {
 				wg.Wait()
-				if err != nil && !errors.Is(err, io.EOF) {
+				if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, openvpn.ErrConnectionTerminated) {
 					require.NoError(t, err)
 				}
 			}
@@ -309,6 +309,7 @@ func TestClientInvalidPassword(t *testing.T) {
 		testutils.SendLine(t, conn, "ENTER PASSWORD:")
 		assert.Equal(t, conf.OpenVpn.Password.String(), testutils.ReadLine(t, reader))
 		testutils.SendLine(t, conn, "ERROR: bad password\r\n")
+		testutils.SendLine(t, conn, "\r\n")
 	}()
 
 	err := client.Connect()
diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml
index 069e4ef..012ced0 100644
--- a/tests/docker-compose.yaml
+++ b/tests/docker-compose.yaml
@@ -12,9 +12,7 @@ services:
       - /dev/net/tun
     ports:
       - "0.0.0.0:1194:1194/udp"
-      - "8080:8080/tcp"
       - "8081:8081/tcp"
-      - "9001:9001/tcp"
     volumes:
       - ./:/tests/:ro
       - ./data:/etc/openvpn
diff --git a/tests/systemd/Dockerfile b/tests/systemd/Dockerfile
new file mode 100644
index 0000000..60105d5
--- /dev/null
+++ b/tests/systemd/Dockerfile
@@ -0,0 +1,3 @@
+FROM ubuntu:23.10
+
+RUN apt update && apt install ca-certificates systemd openvpn -y
diff --git a/tests/systemd/default.preset b/tests/systemd/default.preset
new file mode 100644
index 0000000..e411d80
--- /dev/null
+++ b/tests/systemd/default.preset
@@ -0,0 +1,2 @@
+enable openvpn-auth-oauth2.service
+enable openvpn@openvpn
diff --git a/tests/systemd/docker-compose.yaml b/tests/systemd/docker-compose.yaml
new file mode 100644
index 0000000..6ed92b1
--- /dev/null
+++ b/tests/systemd/docker-compose.yaml
@@ -0,0 +1,30 @@
+version: '3'
+services:
+  systemd:
+    image: local/openvpn-systemd
+    build:
+      dockerfile: Dockerfile
+    privileged: true
+    tty: true
+    ports:
+      - "0.0.0.0:1195:1194/udp"
+      - "9000:9000/tcp"
+      - "9002:9002/tcp"
+    cap_add:
+      - NET_ADMIN
+    devices:
+      - /dev/net/tun
+    tmpfs:
+      - /run
+      - /run/lock
+      - /tmp
+    cgroup: private
+    volumes:
+      - ./default.preset:/etc/systemd/system-preset/default.preset
+      - ./openvpn-auth-oauth2:/usr/bin/openvpn-auth-oauth2
+      - ../../packaging/openvpn-auth-oauth2.service:/etc/systemd/system/openvpn-auth-oauth2.service
+      - ./openvpn-auth-oauth2.conf:/etc/sysconfig/openvpn-auth-oauth2
+      - ../data/:/etc/openvpn/
+    stop_signal: SIGRTMIN+3
+    entrypoint:
+      - /lib/systemd/systemd