From 856a5b1a5004ea9ca8957c48b985a6e1a8c7627c Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 21 Apr 2026 16:19:24 -0700 Subject: [PATCH 01/23] Add cgo build tags to fix CGO_ENABLED=0 cross-compilation Co-Authored-By: Claude Opus 4.6 (1M context) --- certloader/certstore_disabled.go | 2 +- certloader/certstore_enabled.go | 2 +- certloader/certstore_enabled_test.go | 2 +- certloader/certstore_reload_test.go | 2 +- certstore/{certstore_linux.go => certstore_other.go} | 2 ++ certstore/certstore_test.go | 2 +- certstore/errors_darwin_test.go | 2 +- certstore/main_test.go | 2 +- certstore/testca_test.go | 2 +- socket/launchd_disabled.go | 2 +- 10 files changed, 11 insertions(+), 9 deletions(-) rename certstore/{certstore_linux.go => certstore_other.go} (85%) diff --git a/certloader/certstore_disabled.go b/certloader/certstore_disabled.go index 88206abb03..0e5e551795 100644 --- a/certloader/certstore_disabled.go +++ b/certloader/certstore_disabled.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build !cgo || (!darwin && !windows) /*- * Copyright 2018 Square Inc. diff --git a/certloader/certstore_enabled.go b/certloader/certstore_enabled.go index 9d9d4cd501..6c0883fe69 100644 --- a/certloader/certstore_enabled.go +++ b/certloader/certstore_enabled.go @@ -1,4 +1,4 @@ -//go:build darwin || windows +//go:build cgo && (darwin || windows) /*- * Copyright 2018 Square Inc. diff --git a/certloader/certstore_enabled_test.go b/certloader/certstore_enabled_test.go index f474cf8593..36ec2ace96 100644 --- a/certloader/certstore_enabled_test.go +++ b/certloader/certstore_enabled_test.go @@ -1,4 +1,4 @@ -//go:build darwin || windows +//go:build cgo && (darwin || windows) package certloader diff --git a/certloader/certstore_reload_test.go b/certloader/certstore_reload_test.go index 488c042c5c..9472c77e54 100644 --- a/certloader/certstore_reload_test.go +++ b/certloader/certstore_reload_test.go @@ -1,4 +1,4 @@ -//go:build darwin || windows +//go:build cgo && (darwin || windows) package certloader diff --git a/certstore/certstore_linux.go b/certstore/certstore_other.go similarity index 85% rename from certstore/certstore_linux.go rename to certstore/certstore_other.go index 68bb3c0d35..0ae63df85c 100644 --- a/certstore/certstore_linux.go +++ b/certstore/certstore_other.go @@ -1,3 +1,5 @@ +//go:build !cgo || (!darwin && !windows) + package certstore import ( diff --git a/certstore/certstore_test.go b/certstore/certstore_test.go index c39244f602..fef7e41985 100644 --- a/certstore/certstore_test.go +++ b/certstore/certstore_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build cgo && (darwin || windows) package certstore diff --git a/certstore/errors_darwin_test.go b/certstore/errors_darwin_test.go index 1aa10602f7..a1e8236a4b 100644 --- a/certstore/errors_darwin_test.go +++ b/certstore/errors_darwin_test.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo package certstore diff --git a/certstore/main_test.go b/certstore/main_test.go index f690e3b2d2..263c7d4186 100644 --- a/certstore/main_test.go +++ b/certstore/main_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build cgo && (darwin || windows) package certstore diff --git a/certstore/testca_test.go b/certstore/testca_test.go index e0fc3c9ebe..cec338a416 100644 --- a/certstore/testca_test.go +++ b/certstore/testca_test.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build cgo && (darwin || windows) package certstore diff --git a/socket/launchd_disabled.go b/socket/launchd_disabled.go index e3a3852c6e..4556791928 100644 --- a/socket/launchd_disabled.go +++ b/socket/launchd_disabled.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin || !cgo /*- * Copyright 2019 Square Inc. From d646d5629feae25d4abe15d4b4ffb4a50a807ad0 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 21 Apr 2026 16:20:05 -0700 Subject: [PATCH 02/23] Add --eventlog flag for Windows Event Log support --- docs/getting-started/flags.md | 1 + go.mod | 4 +- go.sum | 3 + main.go | 21 +- socket/launchd_enabled.go | 2 +- tests/test-server-system-log.py | 43 ++++ unix.go | 14 +- vendor/github.com/pkg/errors/.gitignore | 24 ++ vendor/github.com/pkg/errors/.travis.yml | 10 + vendor/github.com/pkg/errors/LICENSE | 23 ++ vendor/github.com/pkg/errors/Makefile | 44 ++++ vendor/github.com/pkg/errors/README.md | 59 +++++ vendor/github.com/pkg/errors/appveyor.yml | 32 +++ vendor/github.com/pkg/errors/errors.go | 288 ++++++++++++++++++++++ vendor/github.com/pkg/errors/go113.go | 38 +++ vendor/github.com/pkg/errors/stack.go | 177 +++++++++++++ vendor/modules.txt | 3 + windows.go | 52 +++- 18 files changed, 821 insertions(+), 17 deletions(-) create mode 100644 tests/test-server-system-log.py create mode 100644 vendor/github.com/pkg/errors/.gitignore create mode 100644 vendor/github.com/pkg/errors/.travis.yml create mode 100644 vendor/github.com/pkg/errors/LICENSE create mode 100644 vendor/github.com/pkg/errors/Makefile create mode 100644 vendor/github.com/pkg/errors/README.md create mode 100644 vendor/github.com/pkg/errors/appveyor.yml create mode 100644 vendor/github.com/pkg/errors/errors.go create mode 100644 vendor/github.com/pkg/errors/go113.go create mode 100644 vendor/github.com/pkg/errors/stack.go diff --git a/docs/getting-started/flags.md b/docs/getting-started/flags.md index 07c4e6489b..3da3d2b856 100644 --- a/docs/getting-started/flags.md +++ b/docs/getting-started/flags.md @@ -84,6 +84,7 @@ metrics endpoints, and profiling. | `--enable-shutdown` | Enable `/_shutdown` endpoint alongside `/_status` to allow terminating via HTTP POST. | All platforms | | `--quiet` | Silence log messages. Values: `all`, `conns`, `conn-errs`, `handshake-errs`. Can be repeated. | All platforms | | `--syslog` | Send logs to syslog instead of stdout. | Linux, macOS | +| `--eventlog` | Send logs to Windows Event Log instead of stdout. | Windows | | `--skip-resolve` | Skip resolving target host on startup (useful to start before network is up). | All platforms | ### Landlock diff --git a/go.mod b/go.mod index d06c2e9117..80a6216ada 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,8 @@ require ( software.sslmate.com/src/go-pkcs12 v0.7.0 ) +require github.com/pkg/errors v0.9.1 // indirect + require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect @@ -266,7 +268,7 @@ require ( golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index 4dea0025f3..1edce71302 100644 --- a/go.sum +++ b/go.sum @@ -460,6 +460,9 @@ github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9F github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/main.go b/main.go index b35f9a4e4e..872367d0ec 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ import ( kingpin "github.com/alecthomas/kingpin/v2" graphite "github.com/cyberdelia/go-metrics-graphite" - gsyslog "github.com/hashicorp/go-syslog" + "github.com/prometheus/client_golang/prometheus/promhttp" metrics "github.com/rcrowley/go-metrics" sqmetrics "github.com/square/go-sq-metrics" @@ -210,21 +210,18 @@ type Environment struct { // Global logger instance var logger = log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds) -func initLogger(syslog bool, flags []string) (err error) { - // If user has indicated request for syslog, override default stdout - // logger with a syslog one instead. This can fail, e.g. in containers - // that don't have syslog available. +func initLogger(systemLog bool, flags []string) (err error) { + // If user has indicated request for system log (syslog on Unix, event + // log on Windows), override default stdout logger with the platform + // system logger instead. This can fail, e.g. in containers that don't + // have syslog available. if slices.Contains(flags, "all") { // If --quiet=all if passed, disable all logging logger = log.New(io.Discard, "", 0) return } - if syslog { - var syslogWriter gsyslog.Syslogger - syslogWriter, err = gsyslog.NewLogger(gsyslog.LOG_INFO, "DAEMON", "") - if err == nil { - logger = log.New(syslogWriter, "", log.LstdFlags|log.Lmicroseconds) - } + if systemLog { + err = initSystemLogger() } return } @@ -500,7 +497,7 @@ func run(args []string) error { } // Logger - err := initLogger(useSyslog(), *quiet) + err := initLogger(useSystemLog(), *quiet) if err != nil { return fmt.Errorf("unable to set up logger: %w", err) } diff --git a/socket/launchd_enabled.go b/socket/launchd_enabled.go index 5a708bc784..94aea1f14a 100644 --- a/socket/launchd_enabled.go +++ b/socket/launchd_enabled.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && cgo /*- * Copyright 2019 Square Inc. diff --git a/tests/test-server-system-log.py b/tests/test-server-system-log.py new file mode 100644 index 0000000000..4257cc5b58 --- /dev/null +++ b/tests/test-server-system-log.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +""" +Tests that ghostunnel server works with platform-specific system logging +(--syslog on Unix/macOS, --eventlog on Windows). +""" + +import sys + +from common import (IS_WINDOWS, LISTEN_PORT, TARGET_PORT, SocketPair, + TcpServer, TlsClient, create_default_certs, print_ok, + start_ghostunnel_server, terminate, wait_for_status) + +if IS_WINDOWS: + system_log_flag = '--eventlog' +else: + system_log_flag = '--syslog' + +ghostunnel = None +root = None +try: + root = create_default_certs() + ghostunnel = start_ghostunnel_server(extra_args=[system_log_flag]) + + try: + wait_for_status(lambda info: True, timeout=10) + except TimeoutError: + print('ghostunnel failed to start with {0}, skipping'.format( + system_log_flag), file=sys.stderr) + sys.exit(2) + + # validate tunnel works with system logging enabled + pair = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair.validate_can_send_from_client("hello", "client -> server") + pair.validate_can_send_from_server("world", "server -> client") + pair.validate_closing_client_closes_server("client close -> server close") + + print_ok("OK") +finally: + terminate(ghostunnel) + if root: + root.cleanup() diff --git a/unix.go b/unix.go index 8a6d60951c..9fc2040704 100644 --- a/unix.go +++ b/unix.go @@ -19,8 +19,11 @@ package main import ( + "log" "os" "syscall" + + gsyslog "github.com/hashicorp/go-syslog" ) var ( @@ -29,6 +32,15 @@ var ( syslogFlag = app.Flag("syslog", "Send logs to syslog instead of stdout (Unix/macOS only).").Bool() ) -func useSyslog() bool { +func useSystemLog() bool { return *syslogFlag } + +func initSystemLogger() error { + w, err := gsyslog.NewLogger(gsyslog.LOG_INFO, "DAEMON", "") + if err != nil { + return err + } + logger = log.New(w, "", log.LstdFlags|log.Lmicroseconds) + return nil +} diff --git a/vendor/github.com/pkg/errors/.gitignore b/vendor/github.com/pkg/errors/.gitignore new file mode 100644 index 0000000000..daf913b1b3 --- /dev/null +++ b/vendor/github.com/pkg/errors/.gitignore @@ -0,0 +1,24 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/vendor/github.com/pkg/errors/.travis.yml b/vendor/github.com/pkg/errors/.travis.yml new file mode 100644 index 0000000000..9159de03e0 --- /dev/null +++ b/vendor/github.com/pkg/errors/.travis.yml @@ -0,0 +1,10 @@ +language: go +go_import_path: github.com/pkg/errors +go: + - 1.11.x + - 1.12.x + - 1.13.x + - tip + +script: + - make check diff --git a/vendor/github.com/pkg/errors/LICENSE b/vendor/github.com/pkg/errors/LICENSE new file mode 100644 index 0000000000..835ba3e755 --- /dev/null +++ b/vendor/github.com/pkg/errors/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2015, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pkg/errors/Makefile b/vendor/github.com/pkg/errors/Makefile new file mode 100644 index 0000000000..ce9d7cded6 --- /dev/null +++ b/vendor/github.com/pkg/errors/Makefile @@ -0,0 +1,44 @@ +PKGS := github.com/pkg/errors +SRCDIRS := $(shell go list -f '{{.Dir}}' $(PKGS)) +GO := go + +check: test vet gofmt misspell unconvert staticcheck ineffassign unparam + +test: + $(GO) test $(PKGS) + +vet: | test + $(GO) vet $(PKGS) + +staticcheck: + $(GO) get honnef.co/go/tools/cmd/staticcheck + staticcheck -checks all $(PKGS) + +misspell: + $(GO) get github.com/client9/misspell/cmd/misspell + misspell \ + -locale GB \ + -error \ + *.md *.go + +unconvert: + $(GO) get github.com/mdempsky/unconvert + unconvert -v $(PKGS) + +ineffassign: + $(GO) get github.com/gordonklaus/ineffassign + find $(SRCDIRS) -name '*.go' | xargs ineffassign + +pedantic: check errcheck + +unparam: + $(GO) get mvdan.cc/unparam + unparam ./... + +errcheck: + $(GO) get github.com/kisielk/errcheck + errcheck $(PKGS) + +gofmt: + @echo Checking code is gofmted + @test -z "$(shell gofmt -s -l -d -e $(SRCDIRS) | tee /dev/stderr)" diff --git a/vendor/github.com/pkg/errors/README.md b/vendor/github.com/pkg/errors/README.md new file mode 100644 index 0000000000..54dfdcb12e --- /dev/null +++ b/vendor/github.com/pkg/errors/README.md @@ -0,0 +1,59 @@ +# errors [![Travis-CI](https://travis-ci.org/pkg/errors.svg)](https://travis-ci.org/pkg/errors) [![AppVeyor](https://ci.appveyor.com/api/projects/status/b98mptawhudj53ep/branch/master?svg=true)](https://ci.appveyor.com/project/davecheney/errors/branch/master) [![GoDoc](https://godoc.org/github.com/pkg/errors?status.svg)](http://godoc.org/github.com/pkg/errors) [![Report card](https://goreportcard.com/badge/github.com/pkg/errors)](https://goreportcard.com/report/github.com/pkg/errors) [![Sourcegraph](https://sourcegraph.com/github.com/pkg/errors/-/badge.svg)](https://sourcegraph.com/github.com/pkg/errors?badge) + +Package errors provides simple error handling primitives. + +`go get github.com/pkg/errors` + +The traditional error handling idiom in Go is roughly akin to +```go +if err != nil { + return err +} +``` +which applied recursively up the call stack results in error reports without context or debugging information. The errors package allows programmers to add context to the failure path in their code in a way that does not destroy the original value of the error. + +## Adding context to an error + +The errors.Wrap function returns a new error that adds context to the original error. For example +```go +_, err := ioutil.ReadAll(r) +if err != nil { + return errors.Wrap(err, "read failed") +} +``` +## Retrieving the cause of an error + +Using `errors.Wrap` constructs a stack of errors, adding context to the preceding error. Depending on the nature of the error it may be necessary to reverse the operation of errors.Wrap to retrieve the original error for inspection. Any error value which implements this interface can be inspected by `errors.Cause`. +```go +type causer interface { + Cause() error +} +``` +`errors.Cause` will recursively retrieve the topmost error which does not implement `causer`, which is assumed to be the original cause. For example: +```go +switch err := errors.Cause(err).(type) { +case *MyError: + // handle specifically +default: + // unknown error +} +``` + +[Read the package documentation for more information](https://godoc.org/github.com/pkg/errors). + +## Roadmap + +With the upcoming [Go2 error proposals](https://go.googlesource.com/proposal/+/master/design/go2draft.md) this package is moving into maintenance mode. The roadmap for a 1.0 release is as follows: + +- 0.9. Remove pre Go 1.9 and Go 1.10 support, address outstanding pull requests (if possible) +- 1.0. Final release. + +## Contributing + +Because of the Go2 errors changes, this package is not accepting proposals for new functionality. With that said, we welcome pull requests, bug fixes and issue reports. + +Before sending a PR, please discuss your change by raising an issue. + +## License + +BSD-2-Clause diff --git a/vendor/github.com/pkg/errors/appveyor.yml b/vendor/github.com/pkg/errors/appveyor.yml new file mode 100644 index 0000000000..a932eade02 --- /dev/null +++ b/vendor/github.com/pkg/errors/appveyor.yml @@ -0,0 +1,32 @@ +version: build-{build}.{branch} + +clone_folder: C:\gopath\src\github.com\pkg\errors +shallow_clone: true # for startup speed + +environment: + GOPATH: C:\gopath + +platform: + - x64 + +# http://www.appveyor.com/docs/installed-software +install: + # some helpful output for debugging builds + - go version + - go env + # pre-installed MinGW at C:\MinGW is 32bit only + # but MSYS2 at C:\msys64 has mingw64 + - set PATH=C:\msys64\mingw64\bin;%PATH% + - gcc --version + - g++ --version + +build_script: + - go install -v ./... + +test_script: + - set PATH=C:\gopath\bin;%PATH% + - go test -v ./... + +#artifacts: +# - path: '%GOPATH%\bin\*.exe' +deploy: off diff --git a/vendor/github.com/pkg/errors/errors.go b/vendor/github.com/pkg/errors/errors.go new file mode 100644 index 0000000000..161aea2582 --- /dev/null +++ b/vendor/github.com/pkg/errors/errors.go @@ -0,0 +1,288 @@ +// Package errors provides simple error handling primitives. +// +// The traditional error handling idiom in Go is roughly akin to +// +// if err != nil { +// return err +// } +// +// which when applied recursively up the call stack results in error reports +// without context or debugging information. The errors package allows +// programmers to add context to the failure path in their code in a way +// that does not destroy the original value of the error. +// +// Adding context to an error +// +// The errors.Wrap function returns a new error that adds context to the +// original error by recording a stack trace at the point Wrap is called, +// together with the supplied message. For example +// +// _, err := ioutil.ReadAll(r) +// if err != nil { +// return errors.Wrap(err, "read failed") +// } +// +// If additional control is required, the errors.WithStack and +// errors.WithMessage functions destructure errors.Wrap into its component +// operations: annotating an error with a stack trace and with a message, +// respectively. +// +// Retrieving the cause of an error +// +// Using errors.Wrap constructs a stack of errors, adding context to the +// preceding error. Depending on the nature of the error it may be necessary +// to reverse the operation of errors.Wrap to retrieve the original error +// for inspection. Any error value which implements this interface +// +// type causer interface { +// Cause() error +// } +// +// can be inspected by errors.Cause. errors.Cause will recursively retrieve +// the topmost error that does not implement causer, which is assumed to be +// the original cause. For example: +// +// switch err := errors.Cause(err).(type) { +// case *MyError: +// // handle specifically +// default: +// // unknown error +// } +// +// Although the causer interface is not exported by this package, it is +// considered a part of its stable public interface. +// +// Formatted printing of errors +// +// All error values returned from this package implement fmt.Formatter and can +// be formatted by the fmt package. The following verbs are supported: +// +// %s print the error. If the error has a Cause it will be +// printed recursively. +// %v see %s +// %+v extended format. Each Frame of the error's StackTrace will +// be printed in detail. +// +// Retrieving the stack trace of an error or wrapper +// +// New, Errorf, Wrap, and Wrapf record a stack trace at the point they are +// invoked. This information can be retrieved with the following interface: +// +// type stackTracer interface { +// StackTrace() errors.StackTrace +// } +// +// The returned errors.StackTrace type is defined as +// +// type StackTrace []Frame +// +// The Frame type represents a call site in the stack trace. Frame supports +// the fmt.Formatter interface that can be used for printing information about +// the stack trace of this error. For example: +// +// if err, ok := err.(stackTracer); ok { +// for _, f := range err.StackTrace() { +// fmt.Printf("%+s:%d\n", f, f) +// } +// } +// +// Although the stackTracer interface is not exported by this package, it is +// considered a part of its stable public interface. +// +// See the documentation for Frame.Format for more details. +package errors + +import ( + "fmt" + "io" +) + +// New returns an error with the supplied message. +// New also records the stack trace at the point it was called. +func New(message string) error { + return &fundamental{ + msg: message, + stack: callers(), + } +} + +// Errorf formats according to a format specifier and returns the string +// as a value that satisfies error. +// Errorf also records the stack trace at the point it was called. +func Errorf(format string, args ...interface{}) error { + return &fundamental{ + msg: fmt.Sprintf(format, args...), + stack: callers(), + } +} + +// fundamental is an error that has a message and a stack, but no caller. +type fundamental struct { + msg string + *stack +} + +func (f *fundamental) Error() string { return f.msg } + +func (f *fundamental) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') { + io.WriteString(s, f.msg) + f.stack.Format(s, verb) + return + } + fallthrough + case 's': + io.WriteString(s, f.msg) + case 'q': + fmt.Fprintf(s, "%q", f.msg) + } +} + +// WithStack annotates err with a stack trace at the point WithStack was called. +// If err is nil, WithStack returns nil. +func WithStack(err error) error { + if err == nil { + return nil + } + return &withStack{ + err, + callers(), + } +} + +type withStack struct { + error + *stack +} + +func (w *withStack) Cause() error { return w.error } + +// Unwrap provides compatibility for Go 1.13 error chains. +func (w *withStack) Unwrap() error { return w.error } + +func (w *withStack) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') { + fmt.Fprintf(s, "%+v", w.Cause()) + w.stack.Format(s, verb) + return + } + fallthrough + case 's': + io.WriteString(s, w.Error()) + case 'q': + fmt.Fprintf(s, "%q", w.Error()) + } +} + +// Wrap returns an error annotating err with a stack trace +// at the point Wrap is called, and the supplied message. +// If err is nil, Wrap returns nil. +func Wrap(err error, message string) error { + if err == nil { + return nil + } + err = &withMessage{ + cause: err, + msg: message, + } + return &withStack{ + err, + callers(), + } +} + +// Wrapf returns an error annotating err with a stack trace +// at the point Wrapf is called, and the format specifier. +// If err is nil, Wrapf returns nil. +func Wrapf(err error, format string, args ...interface{}) error { + if err == nil { + return nil + } + err = &withMessage{ + cause: err, + msg: fmt.Sprintf(format, args...), + } + return &withStack{ + err, + callers(), + } +} + +// WithMessage annotates err with a new message. +// If err is nil, WithMessage returns nil. +func WithMessage(err error, message string) error { + if err == nil { + return nil + } + return &withMessage{ + cause: err, + msg: message, + } +} + +// WithMessagef annotates err with the format specifier. +// If err is nil, WithMessagef returns nil. +func WithMessagef(err error, format string, args ...interface{}) error { + if err == nil { + return nil + } + return &withMessage{ + cause: err, + msg: fmt.Sprintf(format, args...), + } +} + +type withMessage struct { + cause error + msg string +} + +func (w *withMessage) Error() string { return w.msg + ": " + w.cause.Error() } +func (w *withMessage) Cause() error { return w.cause } + +// Unwrap provides compatibility for Go 1.13 error chains. +func (w *withMessage) Unwrap() error { return w.cause } + +func (w *withMessage) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') { + fmt.Fprintf(s, "%+v\n", w.Cause()) + io.WriteString(s, w.msg) + return + } + fallthrough + case 's', 'q': + io.WriteString(s, w.Error()) + } +} + +// Cause returns the underlying cause of the error, if possible. +// An error value has a cause if it implements the following +// interface: +// +// type causer interface { +// Cause() error +// } +// +// If the error does not implement Cause, the original error will +// be returned. If the error is nil, nil will be returned without further +// investigation. +func Cause(err error) error { + type causer interface { + Cause() error + } + + for err != nil { + cause, ok := err.(causer) + if !ok { + break + } + err = cause.Cause() + } + return err +} diff --git a/vendor/github.com/pkg/errors/go113.go b/vendor/github.com/pkg/errors/go113.go new file mode 100644 index 0000000000..be0d10d0c7 --- /dev/null +++ b/vendor/github.com/pkg/errors/go113.go @@ -0,0 +1,38 @@ +// +build go1.13 + +package errors + +import ( + stderrors "errors" +) + +// Is reports whether any error in err's chain matches target. +// +// The chain consists of err itself followed by the sequence of errors obtained by +// repeatedly calling Unwrap. +// +// An error is considered to match a target if it is equal to that target or if +// it implements a method Is(error) bool such that Is(target) returns true. +func Is(err, target error) bool { return stderrors.Is(err, target) } + +// As finds the first error in err's chain that matches target, and if so, sets +// target to that error value and returns true. +// +// The chain consists of err itself followed by the sequence of errors obtained by +// repeatedly calling Unwrap. +// +// An error matches target if the error's concrete value is assignable to the value +// pointed to by target, or if the error has a method As(interface{}) bool such that +// As(target) returns true. In the latter case, the As method is responsible for +// setting target. +// +// As will panic if target is not a non-nil pointer to either a type that implements +// error, or to any interface type. As returns false if err is nil. +func As(err error, target interface{}) bool { return stderrors.As(err, target) } + +// Unwrap returns the result of calling the Unwrap method on err, if err's +// type contains an Unwrap method returning error. +// Otherwise, Unwrap returns nil. +func Unwrap(err error) error { + return stderrors.Unwrap(err) +} diff --git a/vendor/github.com/pkg/errors/stack.go b/vendor/github.com/pkg/errors/stack.go new file mode 100644 index 0000000000..779a8348fb --- /dev/null +++ b/vendor/github.com/pkg/errors/stack.go @@ -0,0 +1,177 @@ +package errors + +import ( + "fmt" + "io" + "path" + "runtime" + "strconv" + "strings" +) + +// Frame represents a program counter inside a stack frame. +// For historical reasons if Frame is interpreted as a uintptr +// its value represents the program counter + 1. +type Frame uintptr + +// pc returns the program counter for this frame; +// multiple frames may have the same PC value. +func (f Frame) pc() uintptr { return uintptr(f) - 1 } + +// file returns the full path to the file that contains the +// function for this Frame's pc. +func (f Frame) file() string { + fn := runtime.FuncForPC(f.pc()) + if fn == nil { + return "unknown" + } + file, _ := fn.FileLine(f.pc()) + return file +} + +// line returns the line number of source code of the +// function for this Frame's pc. +func (f Frame) line() int { + fn := runtime.FuncForPC(f.pc()) + if fn == nil { + return 0 + } + _, line := fn.FileLine(f.pc()) + return line +} + +// name returns the name of this function, if known. +func (f Frame) name() string { + fn := runtime.FuncForPC(f.pc()) + if fn == nil { + return "unknown" + } + return fn.Name() +} + +// Format formats the frame according to the fmt.Formatter interface. +// +// %s source file +// %d source line +// %n function name +// %v equivalent to %s:%d +// +// Format accepts flags that alter the printing of some verbs, as follows: +// +// %+s function name and path of source file relative to the compile time +// GOPATH separated by \n\t (\n\t) +// %+v equivalent to %+s:%d +func (f Frame) Format(s fmt.State, verb rune) { + switch verb { + case 's': + switch { + case s.Flag('+'): + io.WriteString(s, f.name()) + io.WriteString(s, "\n\t") + io.WriteString(s, f.file()) + default: + io.WriteString(s, path.Base(f.file())) + } + case 'd': + io.WriteString(s, strconv.Itoa(f.line())) + case 'n': + io.WriteString(s, funcname(f.name())) + case 'v': + f.Format(s, 's') + io.WriteString(s, ":") + f.Format(s, 'd') + } +} + +// MarshalText formats a stacktrace Frame as a text string. The output is the +// same as that of fmt.Sprintf("%+v", f), but without newlines or tabs. +func (f Frame) MarshalText() ([]byte, error) { + name := f.name() + if name == "unknown" { + return []byte(name), nil + } + return []byte(fmt.Sprintf("%s %s:%d", name, f.file(), f.line())), nil +} + +// StackTrace is stack of Frames from innermost (newest) to outermost (oldest). +type StackTrace []Frame + +// Format formats the stack of Frames according to the fmt.Formatter interface. +// +// %s lists source files for each Frame in the stack +// %v lists the source file and line number for each Frame in the stack +// +// Format accepts flags that alter the printing of some verbs, as follows: +// +// %+v Prints filename, function, and line number for each Frame in the stack. +func (st StackTrace) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + switch { + case s.Flag('+'): + for _, f := range st { + io.WriteString(s, "\n") + f.Format(s, verb) + } + case s.Flag('#'): + fmt.Fprintf(s, "%#v", []Frame(st)) + default: + st.formatSlice(s, verb) + } + case 's': + st.formatSlice(s, verb) + } +} + +// formatSlice will format this StackTrace into the given buffer as a slice of +// Frame, only valid when called with '%s' or '%v'. +func (st StackTrace) formatSlice(s fmt.State, verb rune) { + io.WriteString(s, "[") + for i, f := range st { + if i > 0 { + io.WriteString(s, " ") + } + f.Format(s, verb) + } + io.WriteString(s, "]") +} + +// stack represents a stack of program counters. +type stack []uintptr + +func (s *stack) Format(st fmt.State, verb rune) { + switch verb { + case 'v': + switch { + case st.Flag('+'): + for _, pc := range *s { + f := Frame(pc) + fmt.Fprintf(st, "\n%+v", f) + } + } + } +} + +func (s *stack) StackTrace() StackTrace { + f := make([]Frame, len(*s)) + for i := 0; i < len(f); i++ { + f[i] = Frame((*s)[i]) + } + return f +} + +func callers() *stack { + const depth = 32 + var pcs [depth]uintptr + n := runtime.Callers(3, pcs[:]) + var st stack = pcs[0:n] + return &st +} + +// funcname removes the path prefix component of a function's name reported by func.Name(). +func funcname(name string) string { + i := strings.LastIndex(name, "/") + name = name[i+1:] + i = strings.Index(name, ".") + return name[i+1:] +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 56bc700395..941cad047f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -956,6 +956,9 @@ github.com/pierrec/lz4/internal/xxh32 # github.com/pires/go-proxyproto v0.11.0 ## explicit; go 1.24 github.com/pires/go-proxyproto +# github.com/pkg/errors v0.9.1 +## explicit +github.com/pkg/errors # github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ## explicit github.com/pmezard/go-difflib/difflib diff --git a/windows.go b/windows.go index 387c0e4770..2eabb7e540 100644 --- a/windows.go +++ b/windows.go @@ -19,14 +19,62 @@ package main import ( + "bytes" + "log" "os" + + "golang.org/x/sys/windows" ) var ( shutdownSignals = []os.Signal{os.Interrupt} refreshSignals = []os.Signal{ /* Not supported on Windows */ } + eventlogFlag = app.Flag("eventlog", "Send logs to Windows Event Log instead of stdout (Windows only).").Bool() ) -func useSyslog() bool { - return false +func useSystemLog() bool { + return *eventlogFlag +} + +// eventLogWriter implements io.Writer for the Windows Event Log. +type eventLogWriter struct { + handle windows.Handle +} + +func newEventLogWriter(source string) (*eventLogWriter, error) { + srcPtr, err := windows.UTF16PtrFromString(source) + if err != nil { + return nil, err + } + h, err := windows.RegisterEventSource(nil, srcPtr) + if err != nil { + return nil, err + } + return &eventLogWriter{handle: h}, nil +} + +func (w *eventLogWriter) Write(p []byte) (int, error) { + msg := string(bytes.TrimRight(p, "\n")) + msgPtr, err := windows.UTF16PtrFromString(msg) + if err != nil { + return 0, err + } + err = windows.ReportEvent(w.handle, windows.EVENTLOG_INFORMATION_TYPE, 0, 1, 0, 1, 0, &msgPtr, nil) + if err != nil { + return 0, err + } + return len(p), nil +} + +func (w *eventLogWriter) Close() error { + return windows.DeregisterEventSource(w.handle) +} + +func initSystemLogger() error { + w, err := newEventLogWriter("ghostunnel") + if err != nil { + return err + } + logger = log.New(w, "", log.LstdFlags|log.Lmicroseconds) + return nil } From c69644837a7d5a673d961f269b0c7805185b725e Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 22 Apr 2026 15:47:31 -0700 Subject: [PATCH 03/23] Add additional tests for system logger methods --- main_test.go | 10 ++++++---- unix_test.go | 27 +++++++++++++++++++++++++++ windows_test.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 unix_test.go create mode 100644 windows_test.go diff --git a/main_test.go b/main_test.go index d998508b75..62e3bd7390 100644 --- a/main_test.go +++ b/main_test.go @@ -104,19 +104,21 @@ func TestInitLoggerQuiet(t *testing.T) { assert.NotNil(t, logger, "logger should never be nil after init") } -func TestInitLoggerSyslog(t *testing.T) { +func TestInitLoggerSystemLog(t *testing.T) { originalLogger := logger + defer func() { logger = originalLogger }() + err := initLogger(true, []string{}) - updatedLogger := logger if err != nil { // Tests running in containers often don't have access to syslog, // so we can't depend on syslog being available for testing. If we // get an error from the syslog setup we just warn and skip test. - t.Logf("Error setting up syslog for test, skipping: %s", err) + t.Logf("System log not available for test, skipping: %s", err) + assert.NotNil(t, logger, "logger should never be nil even after error") t.SkipNow() return } - assert.NotEqual(t, originalLogger, updatedLogger, "should have updated logger object") + assert.NotEqual(t, originalLogger, logger, "should have updated logger object") assert.NotNil(t, logger, "logger should never be nil after init") } diff --git a/unix_test.go b/unix_test.go new file mode 100644 index 0000000000..761f7f82aa --- /dev/null +++ b/unix_test.go @@ -0,0 +1,27 @@ +//go:build !windows + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUseSystemLog(t *testing.T) { + assert.False(t, useSystemLog(), "useSystemLog should default to false") +} + +func TestInitSystemLoggerSuccess(t *testing.T) { + originalLogger := logger + defer func() { logger = originalLogger }() + + err := initSystemLogger() + if err != nil { + t.Logf("syslog not available, skipping: %s", err) + t.SkipNow() + return + } + assert.NotEqual(t, originalLogger, logger, "logger should be updated") + assert.NotNil(t, logger) +} diff --git a/windows_test.go b/windows_test.go new file mode 100644 index 0000000000..5b116ca9d1 --- /dev/null +++ b/windows_test.go @@ -0,0 +1,37 @@ +//go:build windows + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUseSystemLog(t *testing.T) { + assert.False(t, useSystemLog(), "useSystemLog should default to false") +} + +func TestEventLogWriterWriteAndClose(t *testing.T) { + w, err := newEventLogWriter("Application") + if err != nil { + t.Fatalf("newEventLogWriter failed: %s", err) + } + defer w.Close() + + n, err := w.Write([]byte("ghostunnel test message\n")) + assert.Nil(t, err) + assert.Equal(t, len("ghostunnel test message\n"), n) +} + +func TestInitSystemLogger(t *testing.T) { + originalLogger := logger + defer func() { logger = originalLogger }() + + err := initSystemLogger() + if err != nil { + t.Fatalf("initSystemLogger failed: %s", err) + } + assert.NotEqual(t, originalLogger, logger, "logger should be updated") + assert.NotNil(t, logger) +} From 23bc8a442af4a2bea1482666a9b93e0cb7f2fe42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 17:29:27 +0000 Subject: [PATCH 04/23] Organize docs into sections Group the 17 doc pages into six sections (Getting Started, Certificates & Identity, Security & Access Control, Networking & Integration, Deployment & Operations, Reference). Adds per-section _index.md files, lowercases filenames, sets aliases so old URLs still resolve, and updates the sidebar and docs list templates to render the grouping. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/_index.md | 9 +++++++++ windows.go | 6 +----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/_index.md b/docs/_index.md index 389444336d..f7c76d9ae1 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -3,3 +3,12 @@ title: Documentation description: Ghostunnel documentation weight: 10 --- + +Documentation for Ghostunnel, organized into the following sections: + +- **Getting Started** — install Ghostunnel, get an mTLS tunnel running, and find the right flag. +- **Certificates & Identity** — certificate formats, ACME, SPIFFE, PKCS#11, and OS keychains. +- **Security & Access Control** — TLS protocol settings and rules that decide who can connect. +- **Networking & Integration** — PROXY protocol, socket activation, graceful shutdown, and metrics. +- **Deployment & Operations** — Docker images and systemd integration. +- **Reference** — generated man pages for each supported platform. diff --git a/windows.go b/windows.go index 2eabb7e540..b224fd27a5 100644 --- a/windows.go +++ b/windows.go @@ -59,11 +59,7 @@ func (w *eventLogWriter) Write(p []byte) (int, error) { if err != nil { return 0, err } - err = windows.ReportEvent(w.handle, windows.EVENTLOG_INFORMATION_TYPE, 0, 1, 0, 1, 0, &msgPtr, nil) - if err != nil { - return 0, err - } - return len(p), nil + return len(p), windows.ReportEvent(w.handle, windows.EVENTLOG_INFORMATION_TYPE, 0, 1, 0, 1, 0, &msgPtr, nil) } func (w *eventLogWriter) Close() error { From 39c8772cc07c923f37823d719583bd57beb04d9a Mon Sep 17 00:00:00 2001 From: Ben Dudley Date: Tue, 21 Apr 2026 13:13:31 -0500 Subject: [PATCH 05/23] Add native Windows Service Control Manager support Ghostunnel can now run as a proper Windows service instead of relying on a scheduled task or external process manager. New flags --install-service, --uninstall-service, and --service-name handle SCM registration. The service starts automatically on boot, responds to stop/shutdown controls from the SCM, and logs to the Windows Event Log. Non-Windows platforms are unaffected via no-op stubs in unix.go. --- go.mod | 2 +- main.go | 12 +- signals.go | 6 + unix.go | 13 + .../golang.org/x/sys/windows/registry/key.go | 227 ++++++++++ .../x/sys/windows/registry/mksyscall.go | 9 + .../x/sys/windows/registry/syscall.go | 32 ++ .../x/sys/windows/registry/value.go | 390 ++++++++++++++++ .../sys/windows/registry/zsyscall_windows.go | 117 +++++ .../x/sys/windows/svc/eventlog/install.go | 80 ++++ .../x/sys/windows/svc/eventlog/log.go | 81 ++++ .../x/sys/windows/svc/mgr/config.go | 204 +++++++++ .../golang.org/x/sys/windows/svc/mgr/mgr.go | 241 ++++++++++ .../x/sys/windows/svc/mgr/recovery.go | 172 +++++++ .../x/sys/windows/svc/mgr/service.go | 128 ++++++ .../golang.org/x/sys/windows/svc/security.go | 100 +++++ .../golang.org/x/sys/windows/svc/service.go | 321 ++++++++++++++ vendor/modules.txt | 4 + windows.go | 26 +- windows_service.go | 418 ++++++++++++++++++ windows_service_test.go | 115 +++++ 21 files changed, 2695 insertions(+), 3 deletions(-) create mode 100644 vendor/golang.org/x/sys/windows/registry/key.go create mode 100644 vendor/golang.org/x/sys/windows/registry/mksyscall.go create mode 100644 vendor/golang.org/x/sys/windows/registry/syscall.go create mode 100644 vendor/golang.org/x/sys/windows/registry/value.go create mode 100644 vendor/golang.org/x/sys/windows/registry/zsyscall_windows.go create mode 100644 vendor/golang.org/x/sys/windows/svc/eventlog/install.go create mode 100644 vendor/golang.org/x/sys/windows/svc/eventlog/log.go create mode 100644 vendor/golang.org/x/sys/windows/svc/mgr/config.go create mode 100644 vendor/golang.org/x/sys/windows/svc/mgr/mgr.go create mode 100644 vendor/golang.org/x/sys/windows/svc/mgr/recovery.go create mode 100644 vendor/golang.org/x/sys/windows/svc/mgr/service.go create mode 100644 vendor/golang.org/x/sys/windows/svc/security.go create mode 100644 vendor/golang.org/x/sys/windows/svc/service.go create mode 100644 windows_service.go create mode 100644 windows_service_test.go diff --git a/go.mod b/go.mod index 80a6216ada..c0d0a7bad7 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/wrouesnel/go.connect-proxy-scheme v0.0.0-20240822095422-f6d0c8f327b9 golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 software.sslmate.com/src/go-pkcs12 v0.7.0 @@ -268,7 +269,6 @@ require ( golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/main.go b/main.go index 872367d0ec..2cd19c302e 100644 --- a/main.go +++ b/main.go @@ -475,6 +475,10 @@ func serverProxyProtoMode() proxy.ProxyProtocolMode { } func main() { + if isRunningAsService() { + runAsService() + return + } err := run(os.Args[1:]) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) @@ -489,7 +493,13 @@ func run(args []string) error { app.Version(fmt.Sprintf("rev %s built with %s (pkcs11: %v, keychain: %v)", version, runtime.Version(), certloader.SupportsPKCS11(), certloader.SupportsKeychain())) app.Validate(validateFlags) app.UsageTemplate(kingpin.LongHelpTemplate) - command := kingpin.MustParse(app.Parse(args)) + + command, parseErr := app.Parse(args) + command = kingpin.MustParse(command, parseErr) + + if handled, err := runServiceCommand(command); handled { + return err + } // use-workload-api-addr implies use-workload-api if *useWorkloadAPIAddr != "" { diff --git a/signals.go b/signals.go index 431a2e44b5..29489bc2c7 100644 --- a/signals.go +++ b/signals.go @@ -70,6 +70,12 @@ func (env *Environment) signalHandler(p *proxy.Proxy) { shutdownFunc() + return + case <-serviceShutdownChan(): // nil on non-Windows; nil channel blocks forever, disabling this case + logger.Printf("Windows service stop requested, shutting down") + + shutdownFunc() + return case sig := <-signals: if isShutdownSignal(sig) { diff --git a/unix.go b/unix.go index 9fc2040704..0b01569678 100644 --- a/unix.go +++ b/unix.go @@ -44,3 +44,16 @@ func initSystemLogger() error { logger = log.New(w, "", log.LstdFlags|log.Lmicroseconds) return nil } + +// serviceShutdownChan returns nil on non-Windows platforms. A nil channel +// in a select statement blocks forever, so the service stop case in +// signalHandler is effectively disabled on Unix. +func serviceShutdownChan() <-chan bool { + return nil +} + +func isRunningAsService() bool { return false } + +func runAsService() {} + +func runServiceCommand(_ string) (bool, error) { return false, nil } diff --git a/vendor/golang.org/x/sys/windows/registry/key.go b/vendor/golang.org/x/sys/windows/registry/key.go new file mode 100644 index 0000000000..7cc6ff3afa --- /dev/null +++ b/vendor/golang.org/x/sys/windows/registry/key.go @@ -0,0 +1,227 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +// Package registry provides access to the Windows registry. +// +// Here is a simple example, opening a registry key and reading a string value from it. +// +// k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) +// if err != nil { +// log.Fatal(err) +// } +// defer k.Close() +// +// s, _, err := k.GetStringValue("SystemRoot") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Windows system root is %q\n", s) +package registry + +import ( + "io" + "runtime" + "syscall" + "time" +) + +const ( + // Registry key security and access rights. + // See https://msdn.microsoft.com/en-us/library/windows/desktop/ms724878.aspx + // for details. + ALL_ACCESS = 0xf003f + CREATE_LINK = 0x00020 + CREATE_SUB_KEY = 0x00004 + ENUMERATE_SUB_KEYS = 0x00008 + EXECUTE = 0x20019 + NOTIFY = 0x00010 + QUERY_VALUE = 0x00001 + READ = 0x20019 + SET_VALUE = 0x00002 + WOW64_32KEY = 0x00200 + WOW64_64KEY = 0x00100 + WRITE = 0x20006 +) + +// Key is a handle to an open Windows registry key. +// Keys can be obtained by calling OpenKey; there are +// also some predefined root keys such as CURRENT_USER. +// Keys can be used directly in the Windows API. +type Key syscall.Handle + +const ( + // Windows defines some predefined root keys that are always open. + // An application can use these keys as entry points to the registry. + // Normally these keys are used in OpenKey to open new keys, + // but they can also be used anywhere a Key is required. + CLASSES_ROOT = Key(syscall.HKEY_CLASSES_ROOT) + CURRENT_USER = Key(syscall.HKEY_CURRENT_USER) + LOCAL_MACHINE = Key(syscall.HKEY_LOCAL_MACHINE) + USERS = Key(syscall.HKEY_USERS) + CURRENT_CONFIG = Key(syscall.HKEY_CURRENT_CONFIG) + PERFORMANCE_DATA = Key(syscall.HKEY_PERFORMANCE_DATA) +) + +// Close closes open key k. +func (k Key) Close() error { + return syscall.RegCloseKey(syscall.Handle(k)) +} + +// OpenKey opens a new key with path name relative to key k. +// It accepts any open key, including CURRENT_USER and others, +// and returns the new key and an error. +// The access parameter specifies desired access rights to the +// key to be opened. +func OpenKey(k Key, path string, access uint32) (Key, error) { + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + var subkey syscall.Handle + err = syscall.RegOpenKeyEx(syscall.Handle(k), p, 0, access, &subkey) + if err != nil { + return 0, err + } + return Key(subkey), nil +} + +// OpenRemoteKey opens a predefined registry key on another +// computer pcname. The key to be opened is specified by k, but +// can only be one of LOCAL_MACHINE, PERFORMANCE_DATA or USERS. +// If pcname is "", OpenRemoteKey returns local computer key. +func OpenRemoteKey(pcname string, k Key) (Key, error) { + var err error + var p *uint16 + if pcname != "" { + p, err = syscall.UTF16PtrFromString(`\\` + pcname) + if err != nil { + return 0, err + } + } + var remoteKey syscall.Handle + err = regConnectRegistry(p, syscall.Handle(k), &remoteKey) + if err != nil { + return 0, err + } + return Key(remoteKey), nil +} + +// ReadSubKeyNames returns the names of subkeys of key k. +// The parameter n controls the number of returned names, +// analogous to the way os.File.Readdirnames works. +func (k Key) ReadSubKeyNames(n int) ([]string, error) { + // RegEnumKeyEx must be called repeatedly and to completion. + // During this time, this goroutine cannot migrate away from + // its current thread. See https://golang.org/issue/49320 and + // https://golang.org/issue/49466. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + names := make([]string, 0) + // Registry key size limit is 255 bytes and described there: + // https://msdn.microsoft.com/library/windows/desktop/ms724872.aspx + buf := make([]uint16, 256) //plus extra room for terminating zero byte +loopItems: + for i := uint32(0); ; i++ { + if n > 0 { + if len(names) == n { + return names, nil + } + } + l := uint32(len(buf)) + for { + err := syscall.RegEnumKeyEx(syscall.Handle(k), i, &buf[0], &l, nil, nil, nil, nil) + if err == nil { + break + } + if err == syscall.ERROR_MORE_DATA { + // Double buffer size and try again. + l = uint32(2 * len(buf)) + buf = make([]uint16, l) + continue + } + if err == _ERROR_NO_MORE_ITEMS { + break loopItems + } + return names, err + } + names = append(names, syscall.UTF16ToString(buf[:l])) + } + if n > len(names) { + return names, io.EOF + } + return names, nil +} + +// CreateKey creates a key named path under open key k. +// CreateKey returns the new key and a boolean flag that reports +// whether the key already existed. +// The access parameter specifies the access rights for the key +// to be created. +func CreateKey(k Key, path string, access uint32) (newk Key, openedExisting bool, err error) { + var h syscall.Handle + var d uint32 + var pathPointer *uint16 + pathPointer, err = syscall.UTF16PtrFromString(path) + if err != nil { + return 0, false, err + } + err = regCreateKeyEx(syscall.Handle(k), pathPointer, + 0, nil, _REG_OPTION_NON_VOLATILE, access, nil, &h, &d) + if err != nil { + return 0, false, err + } + return Key(h), d == _REG_OPENED_EXISTING_KEY, nil +} + +// DeleteKey deletes the subkey path of key k and its values. +func DeleteKey(k Key, path string) error { + pathPointer, err := syscall.UTF16PtrFromString(path) + if err != nil { + return err + } + return regDeleteKey(syscall.Handle(k), pathPointer) +} + +// A KeyInfo describes the statistics of a key. It is returned by Stat. +type KeyInfo struct { + SubKeyCount uint32 + MaxSubKeyLen uint32 // size of the key's subkey with the longest name, in Unicode characters, not including the terminating zero byte + ValueCount uint32 + MaxValueNameLen uint32 // size of the key's longest value name, in Unicode characters, not including the terminating zero byte + MaxValueLen uint32 // longest data component among the key's values, in bytes + lastWriteTime syscall.Filetime +} + +// ModTime returns the key's last write time. +func (ki *KeyInfo) ModTime() time.Time { + lastHigh, lastLow := ki.lastWriteTime.HighDateTime, ki.lastWriteTime.LowDateTime + // 100-nanosecond intervals since January 1, 1601 + hsec := uint64(lastHigh)<<32 + uint64(lastLow) + // Convert _before_ gauging; the nanosecond difference between Epoch (00:00:00 + // UTC, January 1, 1970) and Filetime's zero offset (January 1, 1601) is out + // of bounds for int64: -11644473600*1e7*1e2 < math.MinInt64 + sec := int64(hsec/1e7) - 11644473600 + nsec := int64(hsec%1e7) * 100 + return time.Unix(sec, nsec) +} + +// modTimeZero reports whether the key's last write time is zero. +func (ki *KeyInfo) modTimeZero() bool { + return ki.lastWriteTime.LowDateTime == 0 && ki.lastWriteTime.HighDateTime == 0 +} + +// Stat retrieves information about the open key k. +func (k Key) Stat() (*KeyInfo, error) { + var ki KeyInfo + err := syscall.RegQueryInfoKey(syscall.Handle(k), nil, nil, nil, + &ki.SubKeyCount, &ki.MaxSubKeyLen, nil, &ki.ValueCount, + &ki.MaxValueNameLen, &ki.MaxValueLen, nil, &ki.lastWriteTime) + if err != nil { + return nil, err + } + return &ki, nil +} diff --git a/vendor/golang.org/x/sys/windows/registry/mksyscall.go b/vendor/golang.org/x/sys/windows/registry/mksyscall.go new file mode 100644 index 0000000000..bbf86ccf0c --- /dev/null +++ b/vendor/golang.org/x/sys/windows/registry/mksyscall.go @@ -0,0 +1,9 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build generate + +package registry + +//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go syscall.go diff --git a/vendor/golang.org/x/sys/windows/registry/syscall.go b/vendor/golang.org/x/sys/windows/registry/syscall.go new file mode 100644 index 0000000000..f533091c19 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/registry/syscall.go @@ -0,0 +1,32 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package registry + +import "syscall" + +const ( + _REG_OPTION_NON_VOLATILE = 0 + + _REG_CREATED_NEW_KEY = 1 + _REG_OPENED_EXISTING_KEY = 2 + + _ERROR_NO_MORE_ITEMS syscall.Errno = 259 +) + +func LoadRegLoadMUIString() error { + return procRegLoadMUIStringW.Find() +} + +//sys regCreateKeyEx(key syscall.Handle, subkey *uint16, reserved uint32, class *uint16, options uint32, desired uint32, sa *syscall.SecurityAttributes, result *syscall.Handle, disposition *uint32) (regerrno error) = advapi32.RegCreateKeyExW +//sys regDeleteKey(key syscall.Handle, subkey *uint16) (regerrno error) = advapi32.RegDeleteKeyW +//sys regSetValueEx(key syscall.Handle, valueName *uint16, reserved uint32, vtype uint32, buf *byte, bufsize uint32) (regerrno error) = advapi32.RegSetValueExW +//sys regEnumValue(key syscall.Handle, index uint32, name *uint16, nameLen *uint32, reserved *uint32, valtype *uint32, buf *byte, buflen *uint32) (regerrno error) = advapi32.RegEnumValueW +//sys regDeleteValue(key syscall.Handle, name *uint16) (regerrno error) = advapi32.RegDeleteValueW +//sys regLoadMUIString(key syscall.Handle, name *uint16, buf *uint16, buflen uint32, buflenCopied *uint32, flags uint32, dir *uint16) (regerrno error) = advapi32.RegLoadMUIStringW +//sys regConnectRegistry(machinename *uint16, key syscall.Handle, result *syscall.Handle) (regerrno error) = advapi32.RegConnectRegistryW + +//sys expandEnvironmentStrings(src *uint16, dst *uint16, size uint32) (n uint32, err error) = kernel32.ExpandEnvironmentStringsW diff --git a/vendor/golang.org/x/sys/windows/registry/value.go b/vendor/golang.org/x/sys/windows/registry/value.go new file mode 100644 index 0000000000..a1bcbb2362 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/registry/value.go @@ -0,0 +1,390 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package registry + +import ( + "errors" + "io" + "syscall" + "unicode/utf16" + "unsafe" +) + +const ( + // Registry value types. + NONE = 0 + SZ = 1 + EXPAND_SZ = 2 + BINARY = 3 + DWORD = 4 + DWORD_BIG_ENDIAN = 5 + LINK = 6 + MULTI_SZ = 7 + RESOURCE_LIST = 8 + FULL_RESOURCE_DESCRIPTOR = 9 + RESOURCE_REQUIREMENTS_LIST = 10 + QWORD = 11 +) + +var ( + // ErrShortBuffer is returned when the buffer was too short for the operation. + ErrShortBuffer = syscall.ERROR_MORE_DATA + + // ErrNotExist is returned when a registry key or value does not exist. + ErrNotExist = syscall.ERROR_FILE_NOT_FOUND + + // ErrUnexpectedType is returned by Get*Value when the value's type was unexpected. + ErrUnexpectedType = errors.New("unexpected key value type") +) + +// GetValue retrieves the type and data for the specified value associated +// with an open key k. It fills up buffer buf and returns the retrieved +// byte count n. If buf is too small to fit the stored value it returns +// ErrShortBuffer error along with the required buffer size n. +// If no buffer is provided, it returns true and actual buffer size n. +// If no buffer is provided, GetValue returns the value's type only. +// If the value does not exist, the error returned is ErrNotExist. +// +// GetValue is a low level function. If value's type is known, use the appropriate +// Get*Value function instead. +func (k Key) GetValue(name string, buf []byte) (n int, valtype uint32, err error) { + pname, err := syscall.UTF16PtrFromString(name) + if err != nil { + return 0, 0, err + } + var pbuf *byte + if len(buf) > 0 { + pbuf = (*byte)(unsafe.Pointer(&buf[0])) + } + l := uint32(len(buf)) + err = syscall.RegQueryValueEx(syscall.Handle(k), pname, nil, &valtype, pbuf, &l) + if err != nil { + return int(l), valtype, err + } + return int(l), valtype, nil +} + +func (k Key) getValue(name string, buf []byte) (data []byte, valtype uint32, err error) { + p, err := syscall.UTF16PtrFromString(name) + if err != nil { + return nil, 0, err + } + var t uint32 + n := uint32(len(buf)) + for { + err = syscall.RegQueryValueEx(syscall.Handle(k), p, nil, &t, (*byte)(unsafe.Pointer(&buf[0])), &n) + if err == nil { + return buf[:n], t, nil + } + if err != syscall.ERROR_MORE_DATA { + return nil, 0, err + } + if n <= uint32(len(buf)) { + return nil, 0, err + } + buf = make([]byte, n) + } +} + +// GetStringValue retrieves the string value for the specified +// value name associated with an open key k. It also returns the value's type. +// If value does not exist, GetStringValue returns ErrNotExist. +// If value is not SZ or EXPAND_SZ, it will return the correct value +// type and ErrUnexpectedType. +func (k Key) GetStringValue(name string) (val string, valtype uint32, err error) { + data, typ, err2 := k.getValue(name, make([]byte, 64)) + if err2 != nil { + return "", typ, err2 + } + switch typ { + case SZ, EXPAND_SZ: + default: + return "", typ, ErrUnexpectedType + } + if len(data) == 0 { + return "", typ, nil + } + u := (*[1 << 29]uint16)(unsafe.Pointer(&data[0]))[: len(data)/2 : len(data)/2] + return syscall.UTF16ToString(u), typ, nil +} + +// GetMUIStringValue retrieves the localized string value for +// the specified value name associated with an open key k. +// If the value name doesn't exist or the localized string value +// can't be resolved, GetMUIStringValue returns ErrNotExist. +// GetMUIStringValue panics if the system doesn't support +// regLoadMUIString; use LoadRegLoadMUIString to check if +// regLoadMUIString is supported before calling this function. +func (k Key) GetMUIStringValue(name string) (string, error) { + pname, err := syscall.UTF16PtrFromString(name) + if err != nil { + return "", err + } + + buf := make([]uint16, 1024) + var buflen uint32 + var pdir *uint16 + + err = regLoadMUIString(syscall.Handle(k), pname, &buf[0], uint32(len(buf)), &buflen, 0, pdir) + if err == syscall.ERROR_FILE_NOT_FOUND { // Try fallback path + + // Try to resolve the string value using the system directory as + // a DLL search path; this assumes the string value is of the form + // @[path]\dllname,-strID but with no path given, e.g. @tzres.dll,-320. + + // This approach works with tzres.dll but may have to be revised + // in the future to allow callers to provide custom search paths. + + var s string + s, err = ExpandString("%SystemRoot%\\system32\\") + if err != nil { + return "", err + } + pdir, err = syscall.UTF16PtrFromString(s) + if err != nil { + return "", err + } + + err = regLoadMUIString(syscall.Handle(k), pname, &buf[0], uint32(len(buf)), &buflen, 0, pdir) + } + + for err == syscall.ERROR_MORE_DATA { // Grow buffer if needed + if buflen <= uint32(len(buf)) { + break // Buffer not growing, assume race; break + } + buf = make([]uint16, buflen) + err = regLoadMUIString(syscall.Handle(k), pname, &buf[0], uint32(len(buf)), &buflen, 0, pdir) + } + + if err != nil { + return "", err + } + + return syscall.UTF16ToString(buf), nil +} + +// ExpandString expands environment-variable strings and replaces +// them with the values defined for the current user. +// Use ExpandString to expand EXPAND_SZ strings. +func ExpandString(value string) (string, error) { + if value == "" { + return "", nil + } + p, err := syscall.UTF16PtrFromString(value) + if err != nil { + return "", err + } + r := make([]uint16, 100) + for { + n, err := expandEnvironmentStrings(p, &r[0], uint32(len(r))) + if err != nil { + return "", err + } + if n <= uint32(len(r)) { + return syscall.UTF16ToString(r[:n]), nil + } + r = make([]uint16, n) + } +} + +// GetStringsValue retrieves the []string value for the specified +// value name associated with an open key k. It also returns the value's type. +// If value does not exist, GetStringsValue returns ErrNotExist. +// If value is not MULTI_SZ, it will return the correct value +// type and ErrUnexpectedType. +func (k Key) GetStringsValue(name string) (val []string, valtype uint32, err error) { + data, typ, err2 := k.getValue(name, make([]byte, 64)) + if err2 != nil { + return nil, typ, err2 + } + if typ != MULTI_SZ { + return nil, typ, ErrUnexpectedType + } + if len(data) == 0 { + return nil, typ, nil + } + p := (*[1 << 29]uint16)(unsafe.Pointer(&data[0]))[: len(data)/2 : len(data)/2] + if len(p) == 0 { + return nil, typ, nil + } + if p[len(p)-1] == 0 { + p = p[:len(p)-1] // remove terminating null + } + val = make([]string, 0, 5) + from := 0 + for i, c := range p { + if c == 0 { + val = append(val, string(utf16.Decode(p[from:i]))) + from = i + 1 + } + } + return val, typ, nil +} + +// GetIntegerValue retrieves the integer value for the specified +// value name associated with an open key k. It also returns the value's type. +// If value does not exist, GetIntegerValue returns ErrNotExist. +// If value is not DWORD or QWORD, it will return the correct value +// type and ErrUnexpectedType. +func (k Key) GetIntegerValue(name string) (val uint64, valtype uint32, err error) { + data, typ, err2 := k.getValue(name, make([]byte, 8)) + if err2 != nil { + return 0, typ, err2 + } + switch typ { + case DWORD: + if len(data) != 4 { + return 0, typ, errors.New("DWORD value is not 4 bytes long") + } + var val32 uint32 + copy((*[4]byte)(unsafe.Pointer(&val32))[:], data) + return uint64(val32), DWORD, nil + case QWORD: + if len(data) != 8 { + return 0, typ, errors.New("QWORD value is not 8 bytes long") + } + copy((*[8]byte)(unsafe.Pointer(&val))[:], data) + return val, QWORD, nil + default: + return 0, typ, ErrUnexpectedType + } +} + +// GetBinaryValue retrieves the binary value for the specified +// value name associated with an open key k. It also returns the value's type. +// If value does not exist, GetBinaryValue returns ErrNotExist. +// If value is not BINARY, it will return the correct value +// type and ErrUnexpectedType. +func (k Key) GetBinaryValue(name string) (val []byte, valtype uint32, err error) { + data, typ, err2 := k.getValue(name, make([]byte, 64)) + if err2 != nil { + return nil, typ, err2 + } + if typ != BINARY { + return nil, typ, ErrUnexpectedType + } + return data, typ, nil +} + +func (k Key) setValue(name string, valtype uint32, data []byte) error { + p, err := syscall.UTF16PtrFromString(name) + if err != nil { + return err + } + if len(data) == 0 { + return regSetValueEx(syscall.Handle(k), p, 0, valtype, nil, 0) + } + return regSetValueEx(syscall.Handle(k), p, 0, valtype, &data[0], uint32(len(data))) +} + +// SetDWordValue sets the data and type of a name value +// under key k to value and DWORD. +func (k Key) SetDWordValue(name string, value uint32) error { + return k.setValue(name, DWORD, (*[4]byte)(unsafe.Pointer(&value))[:]) +} + +// SetQWordValue sets the data and type of a name value +// under key k to value and QWORD. +func (k Key) SetQWordValue(name string, value uint64) error { + return k.setValue(name, QWORD, (*[8]byte)(unsafe.Pointer(&value))[:]) +} + +func (k Key) setStringValue(name string, valtype uint32, value string) error { + v, err := syscall.UTF16FromString(value) + if err != nil { + return err + } + buf := (*[1 << 29]byte)(unsafe.Pointer(&v[0]))[: len(v)*2 : len(v)*2] + return k.setValue(name, valtype, buf) +} + +// SetStringValue sets the data and type of a name value +// under key k to value and SZ. The value must not contain a zero byte. +func (k Key) SetStringValue(name, value string) error { + return k.setStringValue(name, SZ, value) +} + +// SetExpandStringValue sets the data and type of a name value +// under key k to value and EXPAND_SZ. The value must not contain a zero byte. +func (k Key) SetExpandStringValue(name, value string) error { + return k.setStringValue(name, EXPAND_SZ, value) +} + +// SetStringsValue sets the data and type of a name value +// under key k to value and MULTI_SZ. The value strings +// must not contain a zero byte. +func (k Key) SetStringsValue(name string, value []string) error { + ss := "" + for _, s := range value { + for i := 0; i < len(s); i++ { + if s[i] == 0 { + return errors.New("string cannot have 0 inside") + } + } + ss += s + "\x00" + } + v := utf16.Encode([]rune(ss + "\x00")) + buf := (*[1 << 29]byte)(unsafe.Pointer(&v[0]))[: len(v)*2 : len(v)*2] + return k.setValue(name, MULTI_SZ, buf) +} + +// SetBinaryValue sets the data and type of a name value +// under key k to value and BINARY. +func (k Key) SetBinaryValue(name string, value []byte) error { + return k.setValue(name, BINARY, value) +} + +// DeleteValue removes a named value from the key k. +func (k Key) DeleteValue(name string) error { + namePointer, err := syscall.UTF16PtrFromString(name) + if err != nil { + return err + } + return regDeleteValue(syscall.Handle(k), namePointer) +} + +// ReadValueNames returns the value names of key k. +// The parameter n controls the number of returned names, +// analogous to the way os.File.Readdirnames works. +func (k Key) ReadValueNames(n int) ([]string, error) { + ki, err := k.Stat() + if err != nil { + return nil, err + } + names := make([]string, 0, ki.ValueCount) + buf := make([]uint16, ki.MaxValueNameLen+1) // extra room for terminating null character +loopItems: + for i := uint32(0); ; i++ { + if n > 0 { + if len(names) == n { + return names, nil + } + } + l := uint32(len(buf)) + for { + err := regEnumValue(syscall.Handle(k), i, &buf[0], &l, nil, nil, nil, nil) + if err == nil { + break + } + if err == syscall.ERROR_MORE_DATA { + // Double buffer size and try again. + l = uint32(2 * len(buf)) + buf = make([]uint16, l) + continue + } + if err == _ERROR_NO_MORE_ITEMS { + break loopItems + } + return names, err + } + names = append(names, syscall.UTF16ToString(buf[:l])) + } + if n > len(names) { + return names, io.EOF + } + return names, nil +} diff --git a/vendor/golang.org/x/sys/windows/registry/zsyscall_windows.go b/vendor/golang.org/x/sys/windows/registry/zsyscall_windows.go new file mode 100644 index 0000000000..bc1ce4360b --- /dev/null +++ b/vendor/golang.org/x/sys/windows/registry/zsyscall_windows.go @@ -0,0 +1,117 @@ +// Code generated by 'go generate'; DO NOT EDIT. + +package registry + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modadvapi32 = windows.NewLazySystemDLL("advapi32.dll") + modkernel32 = windows.NewLazySystemDLL("kernel32.dll") + + procRegConnectRegistryW = modadvapi32.NewProc("RegConnectRegistryW") + procRegCreateKeyExW = modadvapi32.NewProc("RegCreateKeyExW") + procRegDeleteKeyW = modadvapi32.NewProc("RegDeleteKeyW") + procRegDeleteValueW = modadvapi32.NewProc("RegDeleteValueW") + procRegEnumValueW = modadvapi32.NewProc("RegEnumValueW") + procRegLoadMUIStringW = modadvapi32.NewProc("RegLoadMUIStringW") + procRegSetValueExW = modadvapi32.NewProc("RegSetValueExW") + procExpandEnvironmentStringsW = modkernel32.NewProc("ExpandEnvironmentStringsW") +) + +func regConnectRegistry(machinename *uint16, key syscall.Handle, result *syscall.Handle) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegConnectRegistryW.Addr(), uintptr(unsafe.Pointer(machinename)), uintptr(key), uintptr(unsafe.Pointer(result))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regCreateKeyEx(key syscall.Handle, subkey *uint16, reserved uint32, class *uint16, options uint32, desired uint32, sa *syscall.SecurityAttributes, result *syscall.Handle, disposition *uint32) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegCreateKeyExW.Addr(), uintptr(key), uintptr(unsafe.Pointer(subkey)), uintptr(reserved), uintptr(unsafe.Pointer(class)), uintptr(options), uintptr(desired), uintptr(unsafe.Pointer(sa)), uintptr(unsafe.Pointer(result)), uintptr(unsafe.Pointer(disposition))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regDeleteKey(key syscall.Handle, subkey *uint16) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegDeleteKeyW.Addr(), uintptr(key), uintptr(unsafe.Pointer(subkey))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regDeleteValue(key syscall.Handle, name *uint16) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegDeleteValueW.Addr(), uintptr(key), uintptr(unsafe.Pointer(name))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regEnumValue(key syscall.Handle, index uint32, name *uint16, nameLen *uint32, reserved *uint32, valtype *uint32, buf *byte, buflen *uint32) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegEnumValueW.Addr(), uintptr(key), uintptr(index), uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(nameLen)), uintptr(unsafe.Pointer(reserved)), uintptr(unsafe.Pointer(valtype)), uintptr(unsafe.Pointer(buf)), uintptr(unsafe.Pointer(buflen))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regLoadMUIString(key syscall.Handle, name *uint16, buf *uint16, buflen uint32, buflenCopied *uint32, flags uint32, dir *uint16) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegLoadMUIStringW.Addr(), uintptr(key), uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(buf)), uintptr(buflen), uintptr(unsafe.Pointer(buflenCopied)), uintptr(flags), uintptr(unsafe.Pointer(dir))) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func regSetValueEx(key syscall.Handle, valueName *uint16, reserved uint32, vtype uint32, buf *byte, bufsize uint32) (regerrno error) { + r0, _, _ := syscall.SyscallN(procRegSetValueExW.Addr(), uintptr(key), uintptr(unsafe.Pointer(valueName)), uintptr(reserved), uintptr(vtype), uintptr(unsafe.Pointer(buf)), uintptr(bufsize)) + if r0 != 0 { + regerrno = syscall.Errno(r0) + } + return +} + +func expandEnvironmentStrings(src *uint16, dst *uint16, size uint32) (n uint32, err error) { + r0, _, e1 := syscall.SyscallN(procExpandEnvironmentStringsW.Addr(), uintptr(unsafe.Pointer(src)), uintptr(unsafe.Pointer(dst)), uintptr(size)) + n = uint32(r0) + if n == 0 { + err = errnoErr(e1) + } + return +} diff --git a/vendor/golang.org/x/sys/windows/svc/eventlog/install.go b/vendor/golang.org/x/sys/windows/svc/eventlog/install.go new file mode 100644 index 0000000000..1179c38bc7 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/eventlog/install.go @@ -0,0 +1,80 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package eventlog + +import ( + "errors" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + // Log levels. + Info = windows.EVENTLOG_INFORMATION_TYPE + Warning = windows.EVENTLOG_WARNING_TYPE + Error = windows.EVENTLOG_ERROR_TYPE +) + +const addKeyName = `SYSTEM\CurrentControlSet\Services\EventLog\Application` + +// Install modifies PC registry to allow logging with an event source src. +// It adds all required keys and values to the event log registry key. +// Install uses msgFile as the event message file. If useExpandKey is true, +// the event message file is installed as REG_EXPAND_SZ value, +// otherwise as REG_SZ. Use bitwise of log.Error, log.Warning and +// log.Info to specify events supported by the new event source. +func Install(src, msgFile string, useExpandKey bool, eventsSupported uint32) error { + appkey, err := registry.OpenKey(registry.LOCAL_MACHINE, addKeyName, registry.CREATE_SUB_KEY) + if err != nil { + return err + } + defer appkey.Close() + + sk, alreadyExist, err := registry.CreateKey(appkey, src, registry.SET_VALUE) + if err != nil { + return err + } + defer sk.Close() + if alreadyExist { + return errors.New(addKeyName + `\` + src + " registry key already exists") + } + + err = sk.SetDWordValue("CustomSource", 1) + if err != nil { + return err + } + if useExpandKey { + err = sk.SetExpandStringValue("EventMessageFile", msgFile) + } else { + err = sk.SetStringValue("EventMessageFile", msgFile) + } + if err != nil { + return err + } + err = sk.SetDWordValue("TypesSupported", eventsSupported) + if err != nil { + return err + } + return nil +} + +// InstallAsEventCreate is the same as Install, but uses +// %SystemRoot%\System32\EventCreate.exe as the event message file. +func InstallAsEventCreate(src string, eventsSupported uint32) error { + return Install(src, "%SystemRoot%\\System32\\EventCreate.exe", true, eventsSupported) +} + +// Remove deletes all registry elements installed by the correspondent Install. +func Remove(src string) error { + appkey, err := registry.OpenKey(registry.LOCAL_MACHINE, addKeyName, registry.SET_VALUE) + if err != nil { + return err + } + defer appkey.Close() + return registry.DeleteKey(appkey, src) +} diff --git a/vendor/golang.org/x/sys/windows/svc/eventlog/log.go b/vendor/golang.org/x/sys/windows/svc/eventlog/log.go new file mode 100644 index 0000000000..ad40c2f48b --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/eventlog/log.go @@ -0,0 +1,81 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +// Package eventlog implements access to Windows event log. +package eventlog + +import ( + "errors" + "syscall" + + "golang.org/x/sys/windows" +) + +// Log provides access to the system log. +type Log struct { + Handle windows.Handle +} + +// Open retrieves a handle to the specified event log. +func Open(source string) (*Log, error) { + return OpenRemote("", source) +} + +// OpenRemote does the same as Open, but on different computer host. +func OpenRemote(host, source string) (*Log, error) { + if source == "" { + return nil, errors.New("Specify event log source") + } + var hostPointer *uint16 + if host != "" { + var err error + hostPointer, err = syscall.UTF16PtrFromString(host) + if err != nil { + return nil, err + } + } + sourcePointer, err := syscall.UTF16PtrFromString(source) + if err != nil { + return nil, err + } + h, err := windows.RegisterEventSource(hostPointer, sourcePointer) + if err != nil { + return nil, err + } + return &Log{Handle: h}, nil +} + +// Close closes event log l. +func (l *Log) Close() error { + return windows.DeregisterEventSource(l.Handle) +} + +func (l *Log) report(etype uint16, eid uint32, msg string) error { + msgPointer, err := syscall.UTF16PtrFromString(msg) + if err != nil { + return err + } + ss := []*uint16{msgPointer} + return windows.ReportEvent(l.Handle, etype, 0, eid, 0, 1, 0, &ss[0], nil) +} + +// Info writes an information event msg with event id eid to the end of event log l. +// When EventCreate.exe is used, eid must be between 1 and 1000. +func (l *Log) Info(eid uint32, msg string) error { + return l.report(windows.EVENTLOG_INFORMATION_TYPE, eid, msg) +} + +// Warning writes an warning event msg with event id eid to the end of event log l. +// When EventCreate.exe is used, eid must be between 1 and 1000. +func (l *Log) Warning(eid uint32, msg string) error { + return l.report(windows.EVENTLOG_WARNING_TYPE, eid, msg) +} + +// Error writes an error event msg with event id eid to the end of event log l. +// When EventCreate.exe is used, eid must be between 1 and 1000. +func (l *Log) Error(eid uint32, msg string) error { + return l.report(windows.EVENTLOG_ERROR_TYPE, eid, msg) +} diff --git a/vendor/golang.org/x/sys/windows/svc/mgr/config.go b/vendor/golang.org/x/sys/windows/svc/mgr/config.go new file mode 100644 index 0000000000..ec01f96eba --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/mgr/config.go @@ -0,0 +1,204 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package mgr + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // Service start types. + StartManual = windows.SERVICE_DEMAND_START // the service must be started manually + StartAutomatic = windows.SERVICE_AUTO_START // the service will start by itself whenever the computer reboots + StartDisabled = windows.SERVICE_DISABLED // the service cannot be started + + // The severity of the error, and action taken, + // if this service fails to start. + ErrorCritical = windows.SERVICE_ERROR_CRITICAL + ErrorIgnore = windows.SERVICE_ERROR_IGNORE + ErrorNormal = windows.SERVICE_ERROR_NORMAL + ErrorSevere = windows.SERVICE_ERROR_SEVERE +) + +// TODO(brainman): Password is not returned by windows.QueryServiceConfig, not sure how to get it. + +type Config struct { + ServiceType uint32 + StartType uint32 + ErrorControl uint32 + BinaryPathName string // fully qualified path to the service binary file, can also include arguments for an auto-start service + LoadOrderGroup string + TagId uint32 + Dependencies []string + ServiceStartName string // name of the account under which the service should run + DisplayName string + Password string + Description string + SidType uint32 // one of SERVICE_SID_TYPE, the type of sid to use for the service + DelayedAutoStart bool // the service is started after other auto-start services are started plus a short delay +} + +func toStringSlice(ps *uint16) []string { + r := make([]string, 0) + p := unsafe.Pointer(ps) + + for { + s := windows.UTF16PtrToString((*uint16)(p)) + if len(s) == 0 { + break + } + + r = append(r, s) + offset := unsafe.Sizeof(uint16(0)) * (uintptr)(len(s)+1) + p = unsafe.Pointer(uintptr(p) + offset) + } + + return r +} + +// Config retrieves service s configuration parameters. +func (s *Service) Config() (Config, error) { + var p *windows.QUERY_SERVICE_CONFIG + n := uint32(1024) + for { + b := make([]byte, n) + p = (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&b[0])) + err := windows.QueryServiceConfig(s.Handle, p, n, &n) + if err == nil { + break + } + if err.(syscall.Errno) != syscall.ERROR_INSUFFICIENT_BUFFER { + return Config{}, err + } + if n <= uint32(len(b)) { + return Config{}, err + } + } + + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_DESCRIPTION) + if err != nil { + return Config{}, err + } + p2 := (*windows.SERVICE_DESCRIPTION)(unsafe.Pointer(&b[0])) + + b, err = s.queryServiceConfig2(windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO) + if err != nil { + return Config{}, err + } + p3 := (*windows.SERVICE_DELAYED_AUTO_START_INFO)(unsafe.Pointer(&b[0])) + delayedStart := false + if p3.IsDelayedAutoStartUp != 0 { + delayedStart = true + } + + b, err = s.queryServiceConfig2(windows.SERVICE_CONFIG_SERVICE_SID_INFO) + if err != nil { + return Config{}, err + } + sidType := *(*uint32)(unsafe.Pointer(&b[0])) + + return Config{ + ServiceType: p.ServiceType, + StartType: p.StartType, + ErrorControl: p.ErrorControl, + BinaryPathName: windows.UTF16PtrToString(p.BinaryPathName), + LoadOrderGroup: windows.UTF16PtrToString(p.LoadOrderGroup), + TagId: p.TagId, + Dependencies: toStringSlice(p.Dependencies), + ServiceStartName: windows.UTF16PtrToString(p.ServiceStartName), + DisplayName: windows.UTF16PtrToString(p.DisplayName), + Description: windows.UTF16PtrToString(p2.Description), + DelayedAutoStart: delayedStart, + SidType: sidType, + }, nil +} + +func updateDescription(handle windows.Handle, desc string) error { + descPointer, err := toPtr(desc) + if err != nil { + return err + } + d := windows.SERVICE_DESCRIPTION{Description: descPointer} + return windows.ChangeServiceConfig2(handle, + windows.SERVICE_CONFIG_DESCRIPTION, (*byte)(unsafe.Pointer(&d))) +} + +func updateSidType(handle windows.Handle, sidType uint32) error { + return windows.ChangeServiceConfig2(handle, windows.SERVICE_CONFIG_SERVICE_SID_INFO, (*byte)(unsafe.Pointer(&sidType))) +} + +func updateStartUp(handle windows.Handle, isDelayed bool) error { + var d windows.SERVICE_DELAYED_AUTO_START_INFO + if isDelayed { + d.IsDelayedAutoStartUp = 1 + } + return windows.ChangeServiceConfig2(handle, + windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO, (*byte)(unsafe.Pointer(&d))) +} + +// UpdateConfig updates service s configuration parameters. +func (s *Service) UpdateConfig(c Config) error { + binaryPathNamePointer, err := toPtr(c.BinaryPathName) + if err != nil { + return err + } + loadOrderGroupPointer, err := toPtr(c.LoadOrderGroup) + if err != nil { + return err + } + serviceStartNamePointer, err := toPtr(c.ServiceStartName) + if err != nil { + return err + } + passwordPointer, err := toPtr(c.Password) + if err != nil { + return err + } + displayNamePointer, err := toPtr(c.DisplayName) + if err != nil { + return err + } + err = windows.ChangeServiceConfig(s.Handle, c.ServiceType, c.StartType, + c.ErrorControl, binaryPathNamePointer, loadOrderGroupPointer, + nil, toStringBlock(c.Dependencies), serviceStartNamePointer, + passwordPointer, displayNamePointer) + if err != nil { + return err + } + err = updateSidType(s.Handle, c.SidType) + if err != nil { + return err + } + + err = updateStartUp(s.Handle, c.DelayedAutoStart) + if err != nil { + return err + } + + return updateDescription(s.Handle, c.Description) +} + +// queryServiceConfig2 calls Windows QueryServiceConfig2 with infoLevel parameter and returns retrieved service configuration information. +func (s *Service) queryServiceConfig2(infoLevel uint32) ([]byte, error) { + n := uint32(1024) + for { + b := make([]byte, n) + err := windows.QueryServiceConfig2(s.Handle, infoLevel, &b[0], n, &n) + if err == nil { + return b, nil + } + if err.(syscall.Errno) != syscall.ERROR_INSUFFICIENT_BUFFER { + return nil, err + } + if n <= uint32(len(b)) { + return nil, err + } + } +} diff --git a/vendor/golang.org/x/sys/windows/svc/mgr/mgr.go b/vendor/golang.org/x/sys/windows/svc/mgr/mgr.go new file mode 100644 index 0000000000..74755940a1 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/mgr/mgr.go @@ -0,0 +1,241 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +// Package mgr can be used to manage Windows service programs. +// It can be used to install and remove them. It can also start, +// stop and pause them. The package can query / change current +// service state and config parameters. +package mgr + +import ( + "syscall" + "time" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Mgr is used to manage Windows service. +type Mgr struct { + Handle windows.Handle +} + +// Connect establishes a connection to the service control manager. +func Connect() (*Mgr, error) { + return ConnectRemote("") +} + +// ConnectRemote establishes a connection to the +// service control manager on computer named host. +func ConnectRemote(host string) (*Mgr, error) { + var s *uint16 + if host != "" { + var err error + s, err = syscall.UTF16PtrFromString(host) + if err != nil { + return nil, err + } + } + h, err := windows.OpenSCManager(s, nil, windows.SC_MANAGER_ALL_ACCESS) + if err != nil { + return nil, err + } + return &Mgr{Handle: h}, nil +} + +// Disconnect closes connection to the service control manager m. +func (m *Mgr) Disconnect() error { + return windows.CloseServiceHandle(m.Handle) +} + +type LockStatus struct { + IsLocked bool // Whether the SCM has been locked. + Age time.Duration // For how long the SCM has been locked. + Owner string // The name of the user who has locked the SCM. +} + +// LockStatus returns whether the service control manager is locked by +// the system, for how long, and by whom. A locked SCM indicates that +// most service actions will block until the system unlocks the SCM. +func (m *Mgr) LockStatus() (*LockStatus, error) { + bytesNeeded := uint32(unsafe.Sizeof(windows.QUERY_SERVICE_LOCK_STATUS{}) + 1024) + for { + bytes := make([]byte, bytesNeeded) + lockStatus := (*windows.QUERY_SERVICE_LOCK_STATUS)(unsafe.Pointer(&bytes[0])) + err := windows.QueryServiceLockStatus(m.Handle, lockStatus, uint32(len(bytes)), &bytesNeeded) + if err == windows.ERROR_INSUFFICIENT_BUFFER && bytesNeeded >= uint32(unsafe.Sizeof(windows.QUERY_SERVICE_LOCK_STATUS{})) { + continue + } + if err != nil { + return nil, err + } + status := &LockStatus{ + IsLocked: lockStatus.IsLocked != 0, + Age: time.Duration(lockStatus.LockDuration) * time.Second, + Owner: windows.UTF16PtrToString(lockStatus.LockOwner), + } + return status, nil + } +} + +func toPtr(s string) (*uint16, error) { + if len(s) == 0 { + return nil, nil + } + return syscall.UTF16PtrFromString(s) +} + +// toStringBlock terminates strings in ss with 0, and then +// concatenates them together. It also adds extra 0 at the end. +func toStringBlock(ss []string) *uint16 { + if len(ss) == 0 { + return nil + } + t := "" + for _, s := range ss { + if s != "" { + t += s + "\x00" + } + } + if t == "" { + return nil + } + t += "\x00" + return &utf16.Encode([]rune(t))[0] +} + +// CreateService installs new service name on the system. +// The service will be executed by running exepath binary. +// Use config c to specify service parameters. +// Any args will be passed as command-line arguments when +// the service is started; these arguments are distinct from +// the arguments passed to Service.Start or via the "Start +// parameters" field in the service's Properties dialog box. +func (m *Mgr) CreateService(name, exepath string, c Config, args ...string) (*Service, error) { + if c.StartType == 0 { + c.StartType = StartManual + } + if c.ServiceType == 0 { + c.ServiceType = windows.SERVICE_WIN32_OWN_PROCESS + } + s := syscall.EscapeArg(exepath) + for _, v := range args { + s += " " + syscall.EscapeArg(v) + } + namePointer, err := toPtr(name) + if err != nil { + return nil, err + } + displayNamePointer, err := toPtr(c.DisplayName) + if err != nil { + return nil, err + } + sPointer, err := toPtr(s) + if err != nil { + return nil, err + } + loadOrderGroupPointer, err := toPtr(c.LoadOrderGroup) + if err != nil { + return nil, err + } + serviceStartNamePointer, err := toPtr(c.ServiceStartName) + if err != nil { + return nil, err + } + passwordPointer, err := toPtr(c.Password) + if err != nil { + return nil, err + } + h, err := windows.CreateService(m.Handle, namePointer, displayNamePointer, + windows.SERVICE_ALL_ACCESS, c.ServiceType, + c.StartType, c.ErrorControl, sPointer, loadOrderGroupPointer, + nil, toStringBlock(c.Dependencies), serviceStartNamePointer, passwordPointer) + if err != nil { + return nil, err + } + if c.SidType != windows.SERVICE_SID_TYPE_NONE { + err = updateSidType(h, c.SidType) + if err != nil { + windows.DeleteService(h) + windows.CloseServiceHandle(h) + return nil, err + } + } + if c.Description != "" { + err = updateDescription(h, c.Description) + if err != nil { + windows.DeleteService(h) + windows.CloseServiceHandle(h) + return nil, err + } + } + if c.DelayedAutoStart { + err = updateStartUp(h, c.DelayedAutoStart) + if err != nil { + windows.DeleteService(h) + windows.CloseServiceHandle(h) + return nil, err + } + } + return &Service{Name: name, Handle: h}, nil +} + +// OpenService retrieves access to service name, so it can +// be interrogated and controlled. +func (m *Mgr) OpenService(name string) (*Service, error) { + namePointer, err := syscall.UTF16PtrFromString(name) + if err != nil { + return nil, err + } + + h, err := windows.OpenService(m.Handle, namePointer, windows.SERVICE_ALL_ACCESS) + if err != nil { + return nil, err + } + return &Service{Name: name, Handle: h}, nil +} + +// ListServices enumerates services in the specified +// service control manager database m. +// If the caller does not have the SERVICE_QUERY_STATUS +// access right to a service, the service is silently +// omitted from the list of services returned. +func (m *Mgr) ListServices() ([]string, error) { + var err error + var bytesNeeded, servicesReturned uint32 + var buf []byte + for { + var p *byte + if len(buf) > 0 { + p = &buf[0] + } + err = windows.EnumServicesStatusEx(m.Handle, windows.SC_ENUM_PROCESS_INFO, + windows.SERVICE_WIN32, windows.SERVICE_STATE_ALL, + p, uint32(len(buf)), &bytesNeeded, &servicesReturned, nil, nil) + if err == nil { + break + } + if err != syscall.ERROR_MORE_DATA { + return nil, err + } + if bytesNeeded <= uint32(len(buf)) { + return nil, err + } + buf = make([]byte, bytesNeeded) + } + if servicesReturned == 0 { + return nil, nil + } + services := unsafe.Slice((*windows.ENUM_SERVICE_STATUS_PROCESS)(unsafe.Pointer(&buf[0])), int(servicesReturned)) + + var names []string + for _, s := range services { + name := windows.UTF16PtrToString(s.ServiceName) + names = append(names, name) + } + return names, nil +} diff --git a/vendor/golang.org/x/sys/windows/svc/mgr/recovery.go b/vendor/golang.org/x/sys/windows/svc/mgr/recovery.go new file mode 100644 index 0000000000..1a07374838 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/mgr/recovery.go @@ -0,0 +1,172 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package mgr + +import ( + "errors" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // Possible recovery actions that the service control manager can perform. + NoAction = windows.SC_ACTION_NONE // no action + ComputerReboot = windows.SC_ACTION_REBOOT // reboot the computer + ServiceRestart = windows.SC_ACTION_RESTART // restart the service + RunCommand = windows.SC_ACTION_RUN_COMMAND // run a command +) + +// RecoveryAction represents an action that the service control manager can perform when service fails. +// A service is considered failed when it terminates without reporting a status of SERVICE_STOPPED to the service controller. +type RecoveryAction struct { + Type int // one of NoAction, ComputerReboot, ServiceRestart or RunCommand + Delay time.Duration // the time to wait before performing the specified action +} + +// SetRecoveryActions sets actions that service controller performs when service fails and +// the time after which to reset the service failure count to zero if there are no failures, in seconds. +// Specify INFINITE to indicate that service failure count should never be reset. +func (s *Service) SetRecoveryActions(recoveryActions []RecoveryAction, resetPeriod uint32) error { + if recoveryActions == nil { + return errors.New("recoveryActions cannot be nil") + } + actions := []windows.SC_ACTION{} + for _, a := range recoveryActions { + action := windows.SC_ACTION{ + Type: uint32(a.Type), + Delay: uint32(a.Delay.Nanoseconds() / 1000000), + } + actions = append(actions, action) + } + rActions := windows.SERVICE_FAILURE_ACTIONS{ + ActionsCount: uint32(len(actions)), + Actions: &actions[0], + ResetPeriod: resetPeriod, + } + return windows.ChangeServiceConfig2(s.Handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS, (*byte)(unsafe.Pointer(&rActions))) +} + +// RecoveryActions returns actions that service controller performs when service fails. +// The service control manager counts the number of times service s has failed since the system booted. +// The count is reset to 0 if the service has not failed for ResetPeriod seconds. +// When the service fails for the Nth time, the service controller performs the action specified in element [N-1] of returned slice. +// If N is greater than slice length, the service controller repeats the last action in the slice. +func (s *Service) RecoveryActions() ([]RecoveryAction, error) { + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_FAILURE_ACTIONS) + if err != nil { + return nil, err + } + p := (*windows.SERVICE_FAILURE_ACTIONS)(unsafe.Pointer(&b[0])) + if p.Actions == nil { + return nil, err + } + + actions := unsafe.Slice(p.Actions, int(p.ActionsCount)) + var recoveryActions []RecoveryAction + for _, action := range actions { + recoveryActions = append(recoveryActions, RecoveryAction{Type: int(action.Type), Delay: time.Duration(action.Delay) * time.Millisecond}) + } + return recoveryActions, nil +} + +// ResetRecoveryActions deletes both reset period and array of failure actions. +func (s *Service) ResetRecoveryActions() error { + actions := make([]windows.SC_ACTION, 1) + rActions := windows.SERVICE_FAILURE_ACTIONS{ + Actions: &actions[0], + } + return windows.ChangeServiceConfig2(s.Handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS, (*byte)(unsafe.Pointer(&rActions))) +} + +// ResetPeriod is the time after which to reset the service failure +// count to zero if there are no failures, in seconds. +func (s *Service) ResetPeriod() (uint32, error) { + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_FAILURE_ACTIONS) + if err != nil { + return 0, err + } + p := (*windows.SERVICE_FAILURE_ACTIONS)(unsafe.Pointer(&b[0])) + return p.ResetPeriod, nil +} + +// SetRebootMessage sets service s reboot message. +// If msg is "", the reboot message is deleted and no message is broadcast. +func (s *Service) SetRebootMessage(msg string) error { + msgPointer, err := syscall.UTF16PtrFromString(msg) + if err != nil { + return err + } + + rActions := windows.SERVICE_FAILURE_ACTIONS{ + RebootMsg: msgPointer, + } + return windows.ChangeServiceConfig2(s.Handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS, (*byte)(unsafe.Pointer(&rActions))) +} + +// RebootMessage is broadcast to server users before rebooting in response to the ComputerReboot service controller action. +func (s *Service) RebootMessage() (string, error) { + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_FAILURE_ACTIONS) + if err != nil { + return "", err + } + p := (*windows.SERVICE_FAILURE_ACTIONS)(unsafe.Pointer(&b[0])) + return windows.UTF16PtrToString(p.RebootMsg), nil +} + +// SetRecoveryCommand sets the command line of the process to execute in response to the RunCommand service controller action. +// If cmd is "", the command is deleted and no program is run when the service fails. +func (s *Service) SetRecoveryCommand(cmd string) error { + cmdPointer, err := syscall.UTF16PtrFromString(cmd) + if err != nil { + return err + } + + rActions := windows.SERVICE_FAILURE_ACTIONS{ + Command: cmdPointer, + } + return windows.ChangeServiceConfig2(s.Handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS, (*byte)(unsafe.Pointer(&rActions))) +} + +// RecoveryCommand is the command line of the process to execute in response to the RunCommand service controller action. This process runs under the same account as the service. +func (s *Service) RecoveryCommand() (string, error) { + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_FAILURE_ACTIONS) + if err != nil { + return "", err + } + p := (*windows.SERVICE_FAILURE_ACTIONS)(unsafe.Pointer(&b[0])) + return windows.UTF16PtrToString(p.Command), nil +} + +// SetRecoveryActionsOnNonCrashFailures sets the failure actions flag. If the +// flag is set to false, recovery actions will only be performed if the service +// terminates without reporting a status of SERVICE_STOPPED. If the flag is set +// to true, recovery actions are also performed if the service stops with a +// nonzero exit code. +func (s *Service) SetRecoveryActionsOnNonCrashFailures(flag bool) error { + var setting windows.SERVICE_FAILURE_ACTIONS_FLAG + if flag { + setting.FailureActionsOnNonCrashFailures = 1 + } + return windows.ChangeServiceConfig2(s.Handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, (*byte)(unsafe.Pointer(&setting))) +} + +// RecoveryActionsOnNonCrashFailures returns the current value of the failure +// actions flag. If the flag is set to false, recovery actions will only be +// performed if the service terminates without reporting a status of +// SERVICE_STOPPED. If the flag is set to true, recovery actions are also +// performed if the service stops with a nonzero exit code. +func (s *Service) RecoveryActionsOnNonCrashFailures() (bool, error) { + b, err := s.queryServiceConfig2(windows.SERVICE_CONFIG_FAILURE_ACTIONS_FLAG) + if err != nil { + return false, err + } + p := (*windows.SERVICE_FAILURE_ACTIONS_FLAG)(unsafe.Pointer(&b[0])) + return p.FailureActionsOnNonCrashFailures != 0, nil +} diff --git a/vendor/golang.org/x/sys/windows/svc/mgr/service.go b/vendor/golang.org/x/sys/windows/svc/mgr/service.go new file mode 100644 index 0000000000..9f59eab754 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/mgr/service.go @@ -0,0 +1,128 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package mgr + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +// Service is used to access Windows service. +type Service struct { + Name string + Handle windows.Handle +} + +// Delete marks service s for deletion from the service control manager database. +func (s *Service) Delete() error { + return windows.DeleteService(s.Handle) +} + +// Close relinquish access to the service s. +func (s *Service) Close() error { + return windows.CloseServiceHandle(s.Handle) +} + +// Start starts service s. +// args will be passed to svc.Handler.Execute. +func (s *Service) Start(args ...string) error { + var p **uint16 + if len(args) > 0 { + vs := make([]*uint16, len(args)) + for i := range vs { + argPointer, err := syscall.UTF16PtrFromString(args[i]) + if err != nil { + return err + } + vs[i] = argPointer + } + p = &vs[0] + } + return windows.StartService(s.Handle, uint32(len(args)), p) +} + +// Control sends state change request c to the service s. It returns the most +// recent status the service reported to the service control manager, and an +// error if the state change request was not accepted. +// Note that the returned service status is only set if the status change +// request succeeded, or if it failed with error ERROR_INVALID_SERVICE_CONTROL, +// ERROR_SERVICE_CANNOT_ACCEPT_CTRL, or ERROR_SERVICE_NOT_ACTIVE. +func (s *Service) Control(c svc.Cmd) (svc.Status, error) { + var t windows.SERVICE_STATUS + err := windows.ControlService(s.Handle, uint32(c), &t) + if err != nil && + err != windows.ERROR_INVALID_SERVICE_CONTROL && + err != windows.ERROR_SERVICE_CANNOT_ACCEPT_CTRL && + err != windows.ERROR_SERVICE_NOT_ACTIVE { + return svc.Status{}, err + } + return svc.Status{ + State: svc.State(t.CurrentState), + Accepts: svc.Accepted(t.ControlsAccepted), + }, err +} + +// Query returns current status of service s. +func (s *Service) Query() (svc.Status, error) { + var t windows.SERVICE_STATUS_PROCESS + var needed uint32 + err := windows.QueryServiceStatusEx(s.Handle, windows.SC_STATUS_PROCESS_INFO, (*byte)(unsafe.Pointer(&t)), uint32(unsafe.Sizeof(t)), &needed) + if err != nil { + return svc.Status{}, err + } + return svc.Status{ + State: svc.State(t.CurrentState), + Accepts: svc.Accepted(t.ControlsAccepted), + ProcessId: t.ProcessId, + Win32ExitCode: t.Win32ExitCode, + ServiceSpecificExitCode: t.ServiceSpecificExitCode, + }, nil +} + +// ListDependentServices returns the names of the services dependent on service s, which match the given status. +func (s *Service) ListDependentServices(status svc.ActivityStatus) ([]string, error) { + var bytesNeeded, returnedServiceCount uint32 + var services []windows.ENUM_SERVICE_STATUS + for { + var servicesPtr *windows.ENUM_SERVICE_STATUS + if len(services) > 0 { + servicesPtr = &services[0] + } + allocatedBytes := uint32(len(services)) * uint32(unsafe.Sizeof(windows.ENUM_SERVICE_STATUS{})) + err := windows.EnumDependentServices(s.Handle, uint32(status), servicesPtr, allocatedBytes, &bytesNeeded, + &returnedServiceCount) + if err == nil { + break + } + if err != syscall.ERROR_MORE_DATA { + return nil, err + } + if bytesNeeded <= allocatedBytes { + return nil, err + } + // ERROR_MORE_DATA indicates the provided buffer was too small, run the call again after resizing the buffer + requiredSliceLen := bytesNeeded / uint32(unsafe.Sizeof(windows.ENUM_SERVICE_STATUS{})) + if bytesNeeded%uint32(unsafe.Sizeof(windows.ENUM_SERVICE_STATUS{})) != 0 { + requiredSliceLen += 1 + } + services = make([]windows.ENUM_SERVICE_STATUS, requiredSliceLen) + } + if returnedServiceCount == 0 { + return nil, nil + } + + // The slice mutated by EnumDependentServices may have a length greater than returnedServiceCount, any elements + // past that should be ignored. + var dependents []string + for i := 0; i < int(returnedServiceCount); i++ { + dependents = append(dependents, windows.UTF16PtrToString(services[i].ServiceName)) + } + return dependents, nil +} diff --git a/vendor/golang.org/x/sys/windows/svc/security.go b/vendor/golang.org/x/sys/windows/svc/security.go new file mode 100644 index 0000000000..6a1f3c627b --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/security.go @@ -0,0 +1,100 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +package svc + +import ( + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +func allocSid(subAuth0 uint32) (*windows.SID, error) { + var sid *windows.SID + err := windows.AllocateAndInitializeSid(&windows.SECURITY_NT_AUTHORITY, + 1, subAuth0, 0, 0, 0, 0, 0, 0, 0, &sid) + if err != nil { + return nil, err + } + return sid, nil +} + +// IsAnInteractiveSession determines if calling process is running interactively. +// It queries the process token for membership in the Interactive group. +// http://stackoverflow.com/questions/2668851/how-do-i-detect-that-my-application-is-running-as-service-or-in-an-interactive-s +// +// Deprecated: Use IsWindowsService instead. +func IsAnInteractiveSession() (bool, error) { + interSid, err := allocSid(windows.SECURITY_INTERACTIVE_RID) + if err != nil { + return false, err + } + defer windows.FreeSid(interSid) + + serviceSid, err := allocSid(windows.SECURITY_SERVICE_RID) + if err != nil { + return false, err + } + defer windows.FreeSid(serviceSid) + + t, err := windows.OpenCurrentProcessToken() + if err != nil { + return false, err + } + defer t.Close() + + gs, err := t.GetTokenGroups() + if err != nil { + return false, err + } + + for _, g := range gs.AllGroups() { + if windows.EqualSid(g.Sid, interSid) { + return true, nil + } + if windows.EqualSid(g.Sid, serviceSid) { + return false, nil + } + } + return false, nil +} + +// IsWindowsService reports whether the process is currently executing +// as a Windows service. +func IsWindowsService() (bool, error) { + // The below technique looks a bit hairy, but it's actually + // exactly what the .NET framework does for the similarly named function: + // https://github.com/dotnet/extensions/blob/f4066026ca06984b07e90e61a6390ac38152ba93/src/Hosting/WindowsServices/src/WindowsServiceHelpers.cs#L26-L31 + // Specifically, it looks up whether the parent process has session ID zero + // and is called "services". + + var currentProcess windows.PROCESS_BASIC_INFORMATION + infoSize := uint32(unsafe.Sizeof(currentProcess)) + err := windows.NtQueryInformationProcess(windows.CurrentProcess(), windows.ProcessBasicInformation, unsafe.Pointer(¤tProcess), infoSize, &infoSize) + if err != nil { + return false, err + } + var parentProcess *windows.SYSTEM_PROCESS_INFORMATION + for infoSize = uint32((unsafe.Sizeof(*parentProcess) + unsafe.Sizeof(uintptr(0))) * 1024); ; { + parentProcess = (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(&make([]byte, infoSize)[0])) + err = windows.NtQuerySystemInformation(windows.SystemProcessInformation, unsafe.Pointer(parentProcess), infoSize, &infoSize) + if err == nil { + break + } else if err != windows.STATUS_INFO_LENGTH_MISMATCH { + return false, err + } + } + for ; ; parentProcess = (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(uintptr(unsafe.Pointer(parentProcess)) + uintptr(parentProcess.NextEntryOffset))) { + if parentProcess.UniqueProcessID == currentProcess.InheritedFromUniqueProcessId { + return parentProcess.SessionID == 0 && strings.EqualFold("services.exe", parentProcess.ImageName.String()), nil + } + if parentProcess.NextEntryOffset == 0 { + break + } + } + return false, nil +} diff --git a/vendor/golang.org/x/sys/windows/svc/service.go b/vendor/golang.org/x/sys/windows/svc/service.go new file mode 100644 index 0000000000..a9b1c192d3 --- /dev/null +++ b/vendor/golang.org/x/sys/windows/svc/service.go @@ -0,0 +1,321 @@ +// Copyright 2012 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build windows + +// Package svc provides everything required to build Windows service. +package svc + +import ( + "errors" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +// State describes service execution state (Stopped, Running and so on). +type State uint32 + +const ( + Stopped = State(windows.SERVICE_STOPPED) + StartPending = State(windows.SERVICE_START_PENDING) + StopPending = State(windows.SERVICE_STOP_PENDING) + Running = State(windows.SERVICE_RUNNING) + ContinuePending = State(windows.SERVICE_CONTINUE_PENDING) + PausePending = State(windows.SERVICE_PAUSE_PENDING) + Paused = State(windows.SERVICE_PAUSED) +) + +// Cmd represents service state change request. It is sent to a service +// by the service manager, and should be actioned upon by the service. +type Cmd uint32 + +const ( + Stop = Cmd(windows.SERVICE_CONTROL_STOP) + Pause = Cmd(windows.SERVICE_CONTROL_PAUSE) + Continue = Cmd(windows.SERVICE_CONTROL_CONTINUE) + Interrogate = Cmd(windows.SERVICE_CONTROL_INTERROGATE) + Shutdown = Cmd(windows.SERVICE_CONTROL_SHUTDOWN) + ParamChange = Cmd(windows.SERVICE_CONTROL_PARAMCHANGE) + NetBindAdd = Cmd(windows.SERVICE_CONTROL_NETBINDADD) + NetBindRemove = Cmd(windows.SERVICE_CONTROL_NETBINDREMOVE) + NetBindEnable = Cmd(windows.SERVICE_CONTROL_NETBINDENABLE) + NetBindDisable = Cmd(windows.SERVICE_CONTROL_NETBINDDISABLE) + DeviceEvent = Cmd(windows.SERVICE_CONTROL_DEVICEEVENT) + HardwareProfileChange = Cmd(windows.SERVICE_CONTROL_HARDWAREPROFILECHANGE) + PowerEvent = Cmd(windows.SERVICE_CONTROL_POWEREVENT) + SessionChange = Cmd(windows.SERVICE_CONTROL_SESSIONCHANGE) + PreShutdown = Cmd(windows.SERVICE_CONTROL_PRESHUTDOWN) +) + +// Accepted is used to describe commands accepted by the service. +// Note that Interrogate is always accepted. +type Accepted uint32 + +const ( + AcceptStop = Accepted(windows.SERVICE_ACCEPT_STOP) + AcceptShutdown = Accepted(windows.SERVICE_ACCEPT_SHUTDOWN) + AcceptPauseAndContinue = Accepted(windows.SERVICE_ACCEPT_PAUSE_CONTINUE) + AcceptParamChange = Accepted(windows.SERVICE_ACCEPT_PARAMCHANGE) + AcceptNetBindChange = Accepted(windows.SERVICE_ACCEPT_NETBINDCHANGE) + AcceptHardwareProfileChange = Accepted(windows.SERVICE_ACCEPT_HARDWAREPROFILECHANGE) + AcceptPowerEvent = Accepted(windows.SERVICE_ACCEPT_POWEREVENT) + AcceptSessionChange = Accepted(windows.SERVICE_ACCEPT_SESSIONCHANGE) + AcceptPreShutdown = Accepted(windows.SERVICE_ACCEPT_PRESHUTDOWN) +) + +// ActivityStatus allows for services to be selected based on active and inactive categories of service state. +type ActivityStatus uint32 + +const ( + Active = ActivityStatus(windows.SERVICE_ACTIVE) + Inactive = ActivityStatus(windows.SERVICE_INACTIVE) + AnyActivity = ActivityStatus(windows.SERVICE_STATE_ALL) +) + +// Status combines State and Accepted commands to fully describe running service. +type Status struct { + State State + Accepts Accepted + CheckPoint uint32 // used to report progress during a lengthy operation + WaitHint uint32 // estimated time required for a pending operation, in milliseconds + ProcessId uint32 // if the service is running, the process identifier of it, and otherwise zero + Win32ExitCode uint32 // set if the service has exited with a win32 exit code + ServiceSpecificExitCode uint32 // set if the service has exited with a service-specific exit code +} + +// StartReason is the reason that the service was started. +type StartReason uint32 + +const ( + StartReasonDemand = StartReason(windows.SERVICE_START_REASON_DEMAND) + StartReasonAuto = StartReason(windows.SERVICE_START_REASON_AUTO) + StartReasonTrigger = StartReason(windows.SERVICE_START_REASON_TRIGGER) + StartReasonRestartOnFailure = StartReason(windows.SERVICE_START_REASON_RESTART_ON_FAILURE) + StartReasonDelayedAuto = StartReason(windows.SERVICE_START_REASON_DELAYEDAUTO) +) + +// ChangeRequest is sent to the service Handler to request service status change. +type ChangeRequest struct { + Cmd Cmd + EventType uint32 + EventData uintptr + CurrentStatus Status + Context uintptr +} + +// Handler is the interface that must be implemented to build Windows service. +type Handler interface { + // Execute will be called by the package code at the start of + // the service, and the service will exit once Execute completes. + // Inside Execute you must read service change requests from r and + // act accordingly. You must keep service control manager up to date + // about state of your service by writing into s as required. + // args contains service name followed by argument strings passed + // to the service. + // You can provide service exit code in exitCode return parameter, + // with 0 being "no error". You can also indicate if exit code, + // if any, is service specific or not by using svcSpecificEC + // parameter. + Execute(args []string, r <-chan ChangeRequest, s chan<- Status) (svcSpecificEC bool, exitCode uint32) +} + +type ctlEvent struct { + cmd Cmd + eventType uint32 + eventData uintptr + context uintptr + errno uint32 +} + +// service provides access to windows service api. +type service struct { + namePointer *uint16 + h windows.Handle + c chan ctlEvent + handler Handler +} + +type exitCode struct { + isSvcSpecific bool + errno uint32 +} + +func (s *service) updateStatus(status *Status, ec *exitCode) error { + if s.h == 0 { + return errors.New("updateStatus with no service status handle") + } + var t windows.SERVICE_STATUS + t.ServiceType = windows.SERVICE_WIN32_OWN_PROCESS + t.CurrentState = uint32(status.State) + if status.Accepts&AcceptStop != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_STOP + } + if status.Accepts&AcceptShutdown != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_SHUTDOWN + } + if status.Accepts&AcceptPauseAndContinue != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_PAUSE_CONTINUE + } + if status.Accepts&AcceptParamChange != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_PARAMCHANGE + } + if status.Accepts&AcceptNetBindChange != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_NETBINDCHANGE + } + if status.Accepts&AcceptHardwareProfileChange != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_HARDWAREPROFILECHANGE + } + if status.Accepts&AcceptPowerEvent != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_POWEREVENT + } + if status.Accepts&AcceptSessionChange != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_SESSIONCHANGE + } + if status.Accepts&AcceptPreShutdown != 0 { + t.ControlsAccepted |= windows.SERVICE_ACCEPT_PRESHUTDOWN + } + if ec.errno == 0 { + t.Win32ExitCode = windows.NO_ERROR + t.ServiceSpecificExitCode = windows.NO_ERROR + } else if ec.isSvcSpecific { + t.Win32ExitCode = uint32(windows.ERROR_SERVICE_SPECIFIC_ERROR) + t.ServiceSpecificExitCode = ec.errno + } else { + t.Win32ExitCode = ec.errno + t.ServiceSpecificExitCode = windows.NO_ERROR + } + t.CheckPoint = status.CheckPoint + t.WaitHint = status.WaitHint + return windows.SetServiceStatus(s.h, &t) +} + +var ( + initCallbacks sync.Once + ctlHandlerCallback uintptr + serviceMainCallback uintptr +) + +func ctlHandler(ctl, evtype, evdata, context uintptr) uintptr { + e := ctlEvent{cmd: Cmd(ctl), eventType: uint32(evtype), eventData: evdata, context: 123456} // Set context to 123456 to test issue #25660. + theService.c <- e + return 0 +} + +var theService service // This is, unfortunately, a global, which means only one service per process. + +// serviceMain is the entry point called by the service manager, registered earlier by +// the call to StartServiceCtrlDispatcher. +func serviceMain(argc uint32, argv **uint16) uintptr { + handle, err := windows.RegisterServiceCtrlHandlerEx(theService.namePointer, ctlHandlerCallback, 0) + if sysErr, ok := err.(windows.Errno); ok { + return uintptr(sysErr) + } else if err != nil { + return uintptr(windows.ERROR_UNKNOWN_EXCEPTION) + } + theService.h = handle + defer func() { + theService.h = 0 + }() + args16 := unsafe.Slice(argv, int(argc)) + + args := make([]string, len(args16)) + for i, a := range args16 { + args[i] = windows.UTF16PtrToString(a) + } + + cmdsToHandler := make(chan ChangeRequest) + changesFromHandler := make(chan Status) + exitFromHandler := make(chan exitCode) + + go func() { + ss, errno := theService.handler.Execute(args, cmdsToHandler, changesFromHandler) + exitFromHandler <- exitCode{ss, errno} + }() + + ec := exitCode{isSvcSpecific: true, errno: 0} + outcr := ChangeRequest{ + CurrentStatus: Status{State: Stopped}, + } + var outch chan ChangeRequest + inch := theService.c +loop: + for { + select { + case r := <-inch: + if r.errno != 0 { + ec.errno = r.errno + break loop + } + inch = nil + outch = cmdsToHandler + outcr.Cmd = r.cmd + outcr.EventType = r.eventType + outcr.EventData = r.eventData + outcr.Context = r.context + case outch <- outcr: + inch = theService.c + outch = nil + case c := <-changesFromHandler: + err := theService.updateStatus(&c, &ec) + if err != nil { + ec.errno = uint32(windows.ERROR_EXCEPTION_IN_SERVICE) + if err2, ok := err.(windows.Errno); ok { + ec.errno = uint32(err2) + } + break loop + } + outcr.CurrentStatus = c + case ec = <-exitFromHandler: + break loop + } + } + + theService.updateStatus(&Status{State: Stopped}, &ec) + + return windows.NO_ERROR +} + +// Run executes service name by calling appropriate handler function. +func Run(name string, handler Handler) error { + // Check to make sure that the service name is valid. + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return err + } + + initCallbacks.Do(func() { + ctlHandlerCallback = windows.NewCallback(ctlHandler) + serviceMainCallback = windows.NewCallback(serviceMain) + }) + theService.namePointer = namePointer + theService.handler = handler + theService.c = make(chan ctlEvent) + t := []windows.SERVICE_TABLE_ENTRY{ + {ServiceName: namePointer, ServiceProc: serviceMainCallback}, + {ServiceName: nil, ServiceProc: 0}, + } + return windows.StartServiceCtrlDispatcher(&t[0]) +} + +// StatusHandle returns service status handle. It is safe to call this function +// from inside the Handler.Execute because then it is guaranteed to be set. +func StatusHandle() windows.Handle { + return theService.h +} + +// DynamicStartReason returns the reason why the service was started. It is safe +// to call this function from inside the Handler.Execute because then it is +// guaranteed to be set. +func DynamicStartReason() (StartReason, error) { + var allocReason *uint32 + err := windows.QueryServiceDynamicInformation(theService.h, windows.SERVICE_DYNAMIC_INFORMATION_LEVEL_START_REASON, unsafe.Pointer(&allocReason)) + if err != nil { + return 0, err + } + reason := StartReason(*allocReason) + windows.LocalFree(windows.Handle(unsafe.Pointer(allocReason))) + return reason, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 941cad047f..c427fede93 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1345,6 +1345,10 @@ golang.org/x/sync/semaphore golang.org/x/sys/cpu golang.org/x/sys/unix golang.org/x/sys/windows +golang.org/x/sys/windows/registry +golang.org/x/sys/windows/svc +golang.org/x/sys/windows/svc/eventlog +golang.org/x/sys/windows/svc/mgr # golang.org/x/text v0.35.0 ## explicit; go 1.25.0 golang.org/x/text/encoding diff --git a/windows.go b/windows.go index b224fd27a5..4bf131f14a 100644 --- a/windows.go +++ b/windows.go @@ -24,12 +24,17 @@ import ( "os" "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" ) var ( shutdownSignals = []os.Signal{os.Interrupt} refreshSignals = []os.Signal{ /* Not supported on Windows */ } - eventlogFlag = app.Flag("eventlog", "Send logs to Windows Event Log instead of stdout (Windows only).").Bool() + eventlogFlag = app.Flag("eventlog", "Send logs to Windows Event Log instead of stdout (Windows only).").Bool() + + // serviceStopCh is written to by the Windows Service Control Manager stop + // handler to trigger a graceful shutdown of the running proxy. + serviceStopCh = make(chan bool, 1) ) func useSystemLog() bool { @@ -74,3 +79,22 @@ func initSystemLogger() error { logger = log.New(w, "", log.LstdFlags|log.Lmicroseconds) return nil } + +// serviceShutdownChan returns the channel that the Windows SCM stop handler +// signals when the service is asked to stop. +func serviceShutdownChan() <-chan bool { + return serviceStopCh +} + +// isRunningAsService reports whether the process was started by the Windows +// Service Control Manager rather than interactively. +func isRunningAsService() bool { + ok, err := svc.IsWindowsService() + return err == nil && ok +} + +// runAsService hands control to the Windows Service Control Manager. +func runAsService() { + name := currentServiceName() + _ = svc.Run(name, &ghostunnelService{name: name}) +} diff --git a/windows_service.go b/windows_service.go new file mode 100644 index 0000000000..8676a5434c --- /dev/null +++ b/windows_service.go @@ -0,0 +1,418 @@ +//go:build windows + +/*- + * Copyright 2024, Ghostunnel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main + +import ( + "fmt" + "os" + "time" + "unicode" + + "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + serviceNameFlagName = "service-name" + defaultServiceName = "ghostunnel" +) + +var ( + serviceCmd = app.Command("service", "Manage ghostunnel as a Windows service (requires Administrator).") + serviceInstallCmd = serviceCmd.Command("install", "Install and start ghostunnel as a Windows service.") + serviceUninstallCmd = serviceCmd.Command("uninstall", "Stop and remove the ghostunnel Windows service.") + serviceStartCmd = serviceCmd.Command("start", "Start the ghostunnel Windows service.") + serviceStopCmd = serviceCmd.Command("stop", "Stop the ghostunnel Windows service.") + serviceStatusCmd = serviceCmd.Command("status", "Show the status of the ghostunnel Windows service.") + + // Each subcommand carries its own --service-name flag so the flag can + // appear after the subcommand name on the command line, e.g.: + // ghostunnel service install --service-name mysvc -- server ... + serviceInstallName = serviceInstallCmd.Flag(serviceNameFlagName, "Name to use for the Windows service.").Default(defaultServiceName).String() + serviceUninstallName = serviceUninstallCmd.Flag(serviceNameFlagName, "Name to use for the Windows service.").Default(defaultServiceName).String() + serviceStartName = serviceStartCmd.Flag(serviceNameFlagName, "Name to use for the Windows service.").Default(defaultServiceName).String() + serviceStopName = serviceStopCmd.Flag(serviceNameFlagName, "Name to use for the Windows service.").Default(defaultServiceName).String() + serviceStatusName = serviceStatusCmd.Flag(serviceNameFlagName, "Name to use for the Windows service.").Default(defaultServiceName).String() + + // Proxy arguments stored in the service registration; everything after '--'. + serviceInstallArgs = serviceInstallCmd.Arg("args", "Proxy arguments to pass to the service, separated from service flags by '--' (e.g. -- server --listen :8443 --target localhost:8080).").Strings() + +) + +// currentServiceName discovers the name of the Windows service for the current +// process by matching PIDs via the SCM. Returns defaultServiceName on failure. +func currentServiceName() string { + m, err := mgr.Connect() + if err != nil { + return defaultServiceName + } + defer m.Disconnect() + + names, err := m.ListServices() + if err != nil { + return defaultServiceName + } + + myPID := uint32(os.Getpid()) + for _, name := range names { + s, err := m.OpenService(name) + if err != nil { + continue + } + status, err := s.Query() + s.Close() + if err != nil { + continue + } + if status.ProcessId == myPID { + return name + } + } + return defaultServiceName +} + +// ghostunnelService implements svc.Handler so the Windows Service Control +// Manager can start, stop, and interrogate the process. +type ghostunnelService struct { + name string +} + +func (s *ghostunnelService) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + elog, _ := eventlog.Open(s.name) // best-effort; non-critical if event source not registered + if elog != nil { + defer elog.Close() + } + + changes <- svc.Status{State: svc.StartPending} + + done := make(chan error, 1) + go func() { + done <- run(os.Args[1:]) + }() + + changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + if elog != nil { + _ = elog.Info(1, "ghostunnel service started") + } + + for { + select { + case err := <-done: + // ghostunnel exited on its own (e.g. configuration error). + if err != nil { + if elog != nil { + _ = elog.Error(1, fmt.Sprintf("ghostunnel exited with error: %v", err)) + } + return false, 1 + } + return false, 0 + + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + if elog != nil { + _ = elog.Info(1, "ghostunnel service stopping") + } + serviceStopCh <- true + <-done // wait for graceful drain to complete + return false, 0 + } + } + } +} + +func validateServiceName(name string) error { + if name == "" { + return fmt.Errorf("service name cannot be empty") + } + if len(name) > 256 { + return fmt.Errorf("service name must be 256 characters or fewer") + } + for _, c := range name { + if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '-' && c != '_' && c != ' ' { + return fmt.Errorf("service name contains invalid character %q (allowed: letters, digits, hyphens, underscores, spaces)", c) + } + } + return nil +} + +// checkGhostunnelMarker returns an error if the named service was not installed +// by ghostunnel's own service management commands. +func checkGhostunnelMarker(name string) error { + regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Services\`+name+`\Parameters`, + registry.QUERY_VALUE) + if err != nil { + return fmt.Errorf("service %q does not appear to be managed by ghostunnel (registry marker missing)", name) + } + val, _, err := regKey.GetIntegerValue("GhostunnelManaged") + regKey.Close() + if err != nil || val != 1 { + return fmt.Errorf("service %q does not appear to be managed by ghostunnel (registry marker missing)", name) + } + return nil +} + +// stopServiceWithTimeout sends a stop control to the service and waits up to +// 30 seconds for it to reach the Stopped state. +func stopServiceWithTimeout(s *mgr.Service, name string) error { + status, err := s.Query() + if err != nil { + return fmt.Errorf("could not query service %q: %w", name, err) + } + if status.State == svc.Stopped { + return nil + } + if _, err := s.Control(svc.Stop); err != nil { + return fmt.Errorf("could not stop service %q: %w", name, err) + } + deadline := time.Now().Add(30 * time.Second) + for status.State != svc.Stopped { + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for service %q to stop", name) + } + time.Sleep(300 * time.Millisecond) + if status, err = s.Query(); err != nil { + return fmt.Errorf("could not query service %q: %w", name, err) + } + } + return nil +} + +func doInstallService(name string, proxyArgs []string) error { + if err := validateServiceName(name); err != nil { + return err + } + if len(proxyArgs) == 0 { + return fmt.Errorf("no proxy arguments provided; use: ghostunnel service install [--service-name NAME] -- server|client [ARGS...]") + } + + exepath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine executable path: %w", err) + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.CreateService(name, exepath, mgr.Config{ + DisplayName: "Ghostunnel (" + name + ")", + StartType: mgr.StartAutomatic, + Description: "Ghostunnel TLS proxy service.", + }, proxyArgs...) + if err != nil { + return fmt.Errorf("could not create service %q: %w", name, err) + } + defer s.Close() + + // Register an event source so Windows Event Log doesn't show + // "The description for Event ID X from source ghostunnel cannot be found." + if err := eventlog.InstallAsEventCreate(name, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil { + // Non-fatal: the service will still work, log entries just look ugly. + fmt.Fprintf(os.Stderr, "warning: could not register event log source: %v\n", err) + } + + // Write a registry marker so uninstall can confirm this service was + // installed by ghostunnel and not some other application. + regKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Services\`+name+`\Parameters`, + registry.SET_VALUE) + if err != nil { + _ = s.Delete() + return fmt.Errorf("could not write registry marker for service %q: %w", name, err) + } + if err := regKey.SetDWordValue("GhostunnelManaged", 1); err != nil { + regKey.Close() + _ = s.Delete() + return fmt.Errorf("could not write registry marker for service %q: %w", name, err) + } + regKey.Close() + + if err := s.Start(); err != nil { + return fmt.Errorf("service %q installed but could not be started: %w", name, err) + } + + fmt.Printf("Service %q installed and started successfully.\n", name) + return nil +} + +func doUninstallService(name string) error { + if err := validateServiceName(name); err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + return fmt.Errorf("could not open service %q: %w", name, err) + } + defer s.Close() + + if err := checkGhostunnelMarker(name); err != nil { + return err + } + + if err := stopServiceWithTimeout(s, name); err != nil { + return err + } + + if err := s.Delete(); err != nil { + return fmt.Errorf("could not delete service %q: %w", name, err) + } + + if elog, err := eventlog.Open(name); err == nil { + _ = elog.Info(1, fmt.Sprintf("ghostunnel service %q uninstalled", name)) + elog.Close() + } + _ = eventlog.Remove(name) // best-effort; non-critical + + fmt.Printf("Service %q stopped and removed successfully.\n", name) + return nil +} + +func doStartService(name string) error { + if err := validateServiceName(name); err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + return fmt.Errorf("could not open service %q: %w", name, err) + } + defer s.Close() + + if err := checkGhostunnelMarker(name); err != nil { + return err + } + + if err := s.Start(); err != nil { + return fmt.Errorf("could not start service %q: %w", name, err) + } + + fmt.Printf("Service %q started successfully.\n", name) + return nil +} + +func doStopService(name string) error { + if err := validateServiceName(name); err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + return fmt.Errorf("could not open service %q: %w", name, err) + } + defer s.Close() + + if err := checkGhostunnelMarker(name); err != nil { + return err + } + + if err := stopServiceWithTimeout(s, name); err != nil { + return err + } + + fmt.Printf("Service %q stopped successfully.\n", name) + return nil +} + +func doStatusService(name string) error { + if err := validateServiceName(name); err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not connect to service manager: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(name) + if err != nil { + return fmt.Errorf("service %q not found: %w", name, err) + } + defer s.Close() + + status, err := s.Query() + if err != nil { + return fmt.Errorf("could not query service %q: %w", name, err) + } + + stateStr := "unknown" + switch status.State { + case svc.Stopped: + stateStr = "stopped" + case svc.StartPending: + stateStr = "start pending" + case svc.StopPending: + stateStr = "stop pending" + case svc.Running: + stateStr = "running" + case svc.ContinuePending: + stateStr = "continue pending" + case svc.PausePending: + stateStr = "pause pending" + case svc.Paused: + stateStr = "paused" + } + + fmt.Printf("Service %q: %s (PID %d)\n", name, stateStr, status.ProcessId) + return nil +} + +func runServiceCommand(command string) (bool, error) { + switch command { + case serviceInstallCmd.FullCommand(): + return true, doInstallService(*serviceInstallName, *serviceInstallArgs) + case serviceUninstallCmd.FullCommand(): + return true, doUninstallService(*serviceUninstallName) + case serviceStartCmd.FullCommand(): + return true, doStartService(*serviceStartName) + case serviceStopCmd.FullCommand(): + return true, doStopService(*serviceStopName) + case serviceStatusCmd.FullCommand(): + return true, doStatusService(*serviceStatusName) + } + return false, nil +} diff --git a/windows_service_test.go b/windows_service_test.go new file mode 100644 index 0000000000..efd8b9cbd3 --- /dev/null +++ b/windows_service_test.go @@ -0,0 +1,115 @@ +//go:build windows + +package main + +import ( + "strings" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// isWindowsAdmin reports whether the current process has Administrator +// privileges, which are required for service management operations. +func isWindowsAdmin() bool { + const keyCreateSubKey = 0x00000004 + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Services`, + keyCreateSubKey) + if err != nil { + return false + } + key.Close() + return true +} + +func TestValidateServiceName(t *testing.T) { + tests := []struct { + name string + wantErr bool + }{ + {"ghostunnel", false}, + {"my-service", false}, + {"my_service", false}, + {"My Service", false}, + {"a", false}, + {"", true}, + {string(make([]byte, 257)), true}, + {"bad/name", true}, + {"bad\\name", true}, + {"bad", true}, + {"bad@name", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateServiceName(tt.name) + if (err != nil) != tt.wantErr { + t.Errorf("validateServiceName(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + }) + } +} + +func TestCurrentServiceNameNotService(t *testing.T) { + if isRunningAsService() { + t.Skip("running as a Windows service") + } + if got := currentServiceName(); got != defaultServiceName { + t.Errorf("currentServiceName() = %q, want %q", got, defaultServiceName) + } +} + +func TestStatusNonExistentService(t *testing.T) { + if err := doStatusService("ghostunnel-nonexistent-99999"); err == nil { + t.Error("expected error for non-existent service, got nil") + } +} + +// TestServiceLifecycle exercises the full install→status→stop→uninstall +// cycle against the real Windows Service Control Manager. +func TestServiceLifecycle(t *testing.T) { + if !isWindowsAdmin() { + t.Skip("requires Administrator privileges") + } + + const name = "ghostunnel-integration-test" + + // Use "service status" as the proxy args so the service process exits + // promptly without needing TLS certificates. We are testing SCM + // registration, not proxy connectivity. + proxyArgs := []string{"service", "status", "--service-name", name} + + t.Cleanup(func() { + _ = doUninstallService(name) // best-effort cleanup on failure + }) + + // When running under "go test", os.Executable() returns the test binary + // rather than ghostunnel.exe. The SCM will fail to start it as a Windows + // service because testing.Main() never registers a service control handler. + // We tolerate that specific error and verify the SCM registration itself. + if err := doInstallService(name, proxyArgs); err != nil { + if !strings.Contains(err.Error(), "installed but could not be started") { + t.Fatalf("install: %v", err) + } + } + + // Service should be registered in the SCM regardless of whether it started. + if err := doStatusService(name); err != nil { + t.Errorf("status after install: %v", err) + } + + // Service will be stopped already (never started); stopServiceWithTimeout + // handles the already-stopped case gracefully. + if err := doStopService(name); err != nil { + t.Errorf("stop: %v", err) + } + + if err := doUninstallService(name); err != nil { + t.Fatalf("uninstall: %v", err) + } + + // Service must be gone after uninstall. + if err := doStatusService(name); err == nil { + t.Error("expected error querying service after uninstall, got nil") + } +} From 29fbee8a93351cdbeb78d17a6a98c43c0a5d64ec Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Thu, 23 Apr 2026 15:47:59 -0700 Subject: [PATCH 06/23] Fix minor nits in Windows service code --- windows.go | 2 +- windows_service.go | 19 ++++++++++++++++--- windows_service_test.go | 4 ++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/windows.go b/windows.go index 4bf131f14a..ae5dfd2c86 100644 --- a/windows.go +++ b/windows.go @@ -30,7 +30,7 @@ import ( var ( shutdownSignals = []os.Signal{os.Interrupt} refreshSignals = []os.Signal{ /* Not supported on Windows */ } - eventlogFlag = app.Flag("eventlog", "Send logs to Windows Event Log instead of stdout (Windows only).").Bool() + eventlogFlag = app.Flag("eventlog", "Send logs to Windows Event Log instead of stdout (Windows only).").Bool() // serviceStopCh is written to by the Windows Service Control Manager stop // handler to trigger a graceful shutdown of the running proxy. diff --git a/windows_service.go b/windows_service.go index 8676a5434c..eca54e329f 100644 --- a/windows_service.go +++ b/windows_service.go @@ -19,6 +19,7 @@ package main import ( + "errors" "fmt" "os" "time" @@ -30,6 +31,10 @@ import ( "golang.org/x/sys/windows/svc/mgr" ) +// errServiceNotStarted is returned when a service was installed successfully +// but the SCM failed to start it. +var errServiceNotStarted = errors.New("service installed but could not be started") + const ( serviceNameFlagName = "service-name" defaultServiceName = "ghostunnel" @@ -54,11 +59,12 @@ var ( // Proxy arguments stored in the service registration; everything after '--'. serviceInstallArgs = serviceInstallCmd.Arg("args", "Proxy arguments to pass to the service, separated from service flags by '--' (e.g. -- server --listen :8443 --target localhost:8080).").Strings() - ) // currentServiceName discovers the name of the Windows service for the current // process by matching PIDs via the SCM. Returns defaultServiceName on failure. +// This iterates all registered services, which may be slow on systems with many +// services. It runs only once at startup, so the cost is acceptable. func currentServiceName() string { m, err := mgr.Connect() if err != nil { @@ -108,6 +114,10 @@ func (s *ghostunnelService) Execute(_ []string, r <-chan svc.ChangeRequest, chan done <- run(os.Args[1:]) }() + // TODO: run() was launched in a goroutine above but may not have finished + // binding ports yet. Ideally we'd wait for a readiness signal before + // reporting Running to the SCM. This requires a larger refactor to thread + // a readiness callback through the startup path. changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} if elog != nil { _ = elog.Info(1, "ghostunnel service started") @@ -134,7 +144,10 @@ func (s *ghostunnelService) Execute(_ []string, r <-chan svc.ChangeRequest, chan if elog != nil { _ = elog.Info(1, "ghostunnel service stopping") } - serviceStopCh <- true + select { + case serviceStopCh <- true: + default: + } <-done // wait for graceful drain to complete return false, 0 } @@ -253,7 +266,7 @@ func doInstallService(name string, proxyArgs []string) error { regKey.Close() if err := s.Start(); err != nil { - return fmt.Errorf("service %q installed but could not be started: %w", name, err) + return fmt.Errorf("service %q: %w: %w", name, errServiceNotStarted, err) } fmt.Printf("Service %q installed and started successfully.\n", name) diff --git a/windows_service_test.go b/windows_service_test.go index efd8b9cbd3..446b89ecc8 100644 --- a/windows_service_test.go +++ b/windows_service_test.go @@ -3,7 +3,7 @@ package main import ( - "strings" + "errors" "testing" "golang.org/x/sys/windows/registry" @@ -88,7 +88,7 @@ func TestServiceLifecycle(t *testing.T) { // service because testing.Main() never registers a service control handler. // We tolerate that specific error and verify the SCM registration itself. if err := doInstallService(name, proxyArgs); err != nil { - if !strings.Contains(err.Error(), "installed but could not be started") { + if !errors.Is(err, errServiceNotStarted) { t.Fatalf("install: %v", err) } } From c119b3cafcec0cfde25e76631d00a68c64dd78eb Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Thu, 23 Apr 2026 15:59:07 -0700 Subject: [PATCH 07/23] Add Windows service docs, reorganize systemd/launchd docs --- docs/_index.md | 4 +- docs/certificates/formats.md | 5 +- docs/certificates/hsm-pkcs11.md | 2 +- docs/certificates/keychain.md | 2 +- docs/certificates/spiffe-workload-api.md | 9 +- docs/deployment/_index.md | 5 +- docs/deployment/launchd.md | 93 ++++++++++ docs/deployment/systemd.md | 220 +++++++++++++++++++++++ docs/deployment/watchdog.md | 67 ------- docs/deployment/windows-service.md | 104 +++++++++++ docs/getting-started/flags.md | 14 +- docs/getting-started/quickstart.md | 2 +- docs/networking/_index.md | 2 +- docs/networking/graceful-shutdown.md | 4 +- docs/networking/socket-activation.md | 160 ----------------- docs/security/access-flags.md | 9 +- 16 files changed, 448 insertions(+), 254 deletions(-) create mode 100644 docs/deployment/launchd.md create mode 100644 docs/deployment/systemd.md delete mode 100644 docs/deployment/watchdog.md create mode 100644 docs/deployment/windows-service.md delete mode 100644 docs/networking/socket-activation.md diff --git a/docs/_index.md b/docs/_index.md index f7c76d9ae1..a1423edf7d 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -9,6 +9,6 @@ Documentation for Ghostunnel, organized into the following sections: - **Getting Started** — install Ghostunnel, get an mTLS tunnel running, and find the right flag. - **Certificates & Identity** — certificate formats, ACME, SPIFFE, PKCS#11, and OS keychains. - **Security & Access Control** — TLS protocol settings and rules that decide who can connect. -- **Networking & Integration** — PROXY protocol, socket activation, graceful shutdown, and metrics. -- **Deployment & Operations** — Docker images and systemd integration. +- **Networking & Integration** — PROXY protocol, graceful shutdown, and metrics. +- **Deployment & Operations** — Docker images, systemd, launchd, and Windows service integration. - **Reference** — generated man pages for each supported platform. diff --git a/docs/certificates/formats.md b/docs/certificates/formats.md index b714e6d711..04ebbb3138 100644 --- a/docs/certificates/formats.md +++ b/docs/certificates/formats.md @@ -16,7 +16,6 @@ bytes, so you don't need to specify it explicitly. | PEM (combined) | `.pem` | `--keystore` | Single file with cert chain and private key | | PKCS#12 | `.p12`, `.pfx` | `--keystore` | Binary bundle; optional `--storepass` for password | | JCEKS | `.jceks`, `.jks` | `--keystore` | Java keystore; requires `--storepass` | -| DER | `.der` | `--keystore` | Raw X.509 or PKCS#7; less common | These options are mutually exclusive with each other and with `--use-workload-api`, `--keychain-identity`, and PKCS#11 flags. @@ -134,9 +133,9 @@ cat root-ca.pem intermediate-ca.pem > cacert.pem Ghostunnel detects the format in this order: 1. **File extension**: `.pem`/`.crt` → PEM, `.p12`/`.pfx` → PKCS#12, - `.jceks`/`.jks` → JCEKS, `.der` → DER. + `.jceks`/`.jks` → JCEKS. 2. **Magic bytes**: if the extension is ambiguous, the first bytes of the file - are inspected (e.g. `-----BEGIN` → PEM, ASN.1 sequence → PKCS#12 or DER). + are inspected (e.g. `-----BEGIN` → PEM, ASN.1 sequence → PKCS#12). In practice, just use the right file extension and Ghostunnel will do the right thing. diff --git a/docs/certificates/hsm-pkcs11.md b/docs/certificates/hsm-pkcs11.md index 3cc9fb06e0..eb3172292a 100644 --- a/docs/certificates/hsm-pkcs11.md +++ b/docs/certificates/hsm-pkcs11.md @@ -145,7 +145,7 @@ ghostunnel server \ --pkcs11-pin 123456 \ --listen localhost:8443 \ --target localhost:8080 \ - --cacert ca-cert.pem \ + --cacert cacert.pem \ --allow-cn client ``` diff --git a/docs/certificates/keychain.md b/docs/certificates/keychain.md index 9412f22334..327400f563 100644 --- a/docs/certificates/keychain.md +++ b/docs/certificates/keychain.md @@ -79,7 +79,7 @@ ghostunnel server \ --listen localhost:8443 \ --target localhost:8080 \ --cacert cacert.pem \ - --allow-ou=client + --allow-ou client ``` This flag is only available on macOS. diff --git a/docs/certificates/spiffe-workload-api.md b/docs/certificates/spiffe-workload-api.md index 14b92572c7..f8d5026be1 100644 --- a/docs/certificates/spiffe-workload-api.md +++ b/docs/certificates/spiffe-workload-api.md @@ -15,10 +15,11 @@ X.509 roots. Peers are expected to present SPIFFE [X509-SVIDs](https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md), which are verified using SPIFFE authentication. -To enable workload API support, use the `--use-workload-api` flag. By default, -the location of the SPIFFE Workload API socket is picked up from the -`SPIFFE_ENDPOINT_SOCKET` environment variable. If you prefer to specify this via -flag, the `--use-workload-api-addr` flag can be used to explicitly set the address. +To enable workload API support, set the `SPIFFE_ENDPOINT_SOCKET` environment +variable or pass the `--use-workload-api-addr` flag. Either of these implicitly +enables `--use-workload-api`, so the explicit flag is not required when the +address is provided. You can also pass `--use-workload-api` on its own if the +environment variable is already set. On UNIX systems (Linux, macOS): diff --git a/docs/deployment/_index.md b/docs/deployment/_index.md index ac752259bd..37fcb37c38 100644 --- a/docs/deployment/_index.md +++ b/docs/deployment/_index.md @@ -4,5 +4,6 @@ description: Running Ghostunnel as a container or as a supervised system service weight: 50 --- -Published container images and integration with systemd for running Ghostunnel -as a long-lived service. +Published container images and integration with platform service managers +(systemd, launchd, Windows SCM) for running Ghostunnel as a long-lived +service. diff --git a/docs/deployment/launchd.md b/docs/deployment/launchd.md new file mode 100644 index 0000000000..97727f1874 --- /dev/null +++ b/docs/deployment/launchd.md @@ -0,0 +1,93 @@ +--- +title: Launchd (macOS) +description: Run Ghostunnel as a macOS launchd daemon with socket activation. +weight: 20 +--- + +Ghostunnel can run as a macOS daemon managed by [launchd][launchd-guide]. +Launchd socket activation is supported for the `--listen` and `--status` flags +by passing an address of the form `launchd:`, where `` matches the +socket key defined in your plist. + +## Example Plist + +A launchd plist to run Ghostunnel in server mode, listening on `:8081`, +with a status port on `:8082`, forwarding connections to `:8083`: + +```xml + + + + + Label + ghostunnel + ProgramArguments + + /usr/bin/ghostunnel + server + --keystore + /etc/ghostunnel/server-keystore.p12 + --cacert + /etc/ghostunnel/cacert.pem + --target + localhost:8083 + --listen + launchd:Listener + --status + launchd:Status + --allow-cn + client + + StandardOutPath + /var/log/ghostunnel.out.log + StandardErrorPath + /var/log/ghostunnel.err.log + Sockets + + Listener + + SockServiceName + 8081 + SockType + stream + SockFamily + IPv4 + + Status + + SockServiceName + 8082 + SockType + stream + SockFamily + IPv4 + + + + +``` + +Both `SockType` and `SockFamily` must be defined for each socket. If the +family is omitted, launchd opens two sockets (IPv4 and IPv6) for each key, +which Ghostunnel does not currently support. + +## Installing + +```bash +# Copy the plist into place +sudo cp ghostunnel.plist /Library/LaunchDaemons/ + +# Load and start (modern macOS) +sudo launchctl bootstrap system/ /Library/LaunchDaemons/ghostunnel.plist + +# Stop and unload +sudo launchctl bootout system/ghostunnel +``` + +On older macOS versions (before 10.11), use `launchctl load` and +`launchctl unload` instead. + +Use `~/Library/LaunchAgents/` (with `gui//` instead of `system/`) +if running as a user agent rather than a system daemon. + +[launchd-guide]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html diff --git a/docs/deployment/systemd.md b/docs/deployment/systemd.md new file mode 100644 index 0000000000..41e2d97e10 --- /dev/null +++ b/docs/deployment/systemd.md @@ -0,0 +1,220 @@ +--- +title: Systemd (Linux) +description: Run Ghostunnel as a systemd service with socket activation, readiness notification, and watchdog support. +weight: 15 +aliases: + - /docs/watchdog/ + - /docs/socket-activation/ +--- + +Ghostunnel integrates with systemd on Linux for service management, socket +activation, readiness notification, and automatic restart via the watchdog +timer. + +## Basic Service Unit + +The simplest way to run Ghostunnel under systemd is a `Type=simple` service: + +```ini +[Unit] +Description=Ghostunnel +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/ghostunnel server \ + --listen=localhost:8443 \ + --target=localhost:8080 \ + --keystore=/etc/ghostunnel/server-keystore.p12 \ + --cacert=/etc/ghostunnel/cacert.pem \ + --allow-cn=client +Restart=always + +[Install] +WantedBy=default.target +``` + +## Notify and Watchdog + +*Available since v1.8.0.* + +Ghostunnel supports systemd's [notify][sd-notify] and watchdog functionality. +This allows systemd to know when Ghostunnel is ready and to automatically +restart it if it becomes unresponsive. + +When running as a [`Type=notify-reload`][systemd-service] service: + +* **Notify**: Ghostunnel signals readiness to systemd after it has successfully + loaded certificates and started listening. Systemd will not consider the + service "started" until this signal is received. +* **Watchdog**: Ghostunnel periodically sends a heartbeat to systemd at the + interval specified by `WatchdogSec`. If systemd does not receive a heartbeat + within the configured interval, it considers the process hung and takes the + action specified by `Restart` (typically restarting the service). +* **Reload**: When you run `systemctl reload ghostunnel`, systemd sends + `SIGHUP` to the process, which triggers a certificate reload (same as + sending `SIGHUP` manually). + +### Example Unit File + +```ini +[Unit] +Description=Ghostunnel +After=network.target + +[Service] +Type=notify-reload +ExecStart=/usr/bin/ghostunnel server \ + --listen=localhost:8443 \ + --target=localhost:8080 \ + --keystore=/etc/ghostunnel/server-keystore.p12 \ + --cacert=/etc/ghostunnel/cacert.pem \ + --allow-cn=client +WatchdogSec=5 +Restart=always + +[Install] +WantedBy=default.target +``` + +### Notes + +* `Type=notify-reload` requires systemd v253 or later. If you are on an older + version, use `Type=notify` instead (reload via `systemctl reload` will not + work, but you can still send `SIGHUP` manually). +* The `WatchdogSec` value should be set based on your tolerance for downtime. + A value of `5` (5 seconds) is a reasonable default. Very low values (e.g. `1`) + may cause spurious restarts under heavy load. +* Watchdog and notify functionality is only available on Linux. On other + platforms, use `Type=simple` and manage restarts via your service manager's + native mechanisms. + +## Socket Activation + +Ghostunnel supports systemd [socket activation][systemd-socket] for on-demand +startup. Socket activation is supported for the `--listen` and `--status` +flags by passing an address of the form `systemd:`, where `` +matches the `FileDescriptorName` in the socket unit. + +### Socket Unit + +A `ghostunnel.socket` unit for listening on `*:8443`: + +```ini +[Unit] +Description=Ghostunnel Socket +PartOf=ghostunnel.service + +[Socket] +FileDescriptorName=ghostunnel +ListenStream=0.0.0.0:8443 + +[Install] +WantedBy=sockets.target +``` + +### Corresponding Service Unit + +A `ghostunnel.service` that forwards to `localhost:8080`: + +```ini +[Unit] +Description=Ghostunnel +After=network.target ghostunnel.socket +Requires=ghostunnel.socket + +[Service] +Type=simple +ExecStart=/usr/bin/ghostunnel server \ + --listen=systemd:ghostunnel \ + --target=localhost:8080 \ + --keystore=/etc/ghostunnel/server-keystore.p12 \ + --cacert=/etc/ghostunnel/cacert.pem \ + --allow-cn=client + +[Install] +WantedBy=default.target +``` + +The `FileDescriptorName` in `ghostunnel.socket` must match the name passed to +`--listen`. If multiple sockets are needed (e.g. for a status port), use the +name to distinguish them. + +### Installing + +```bash +# Copy unit files into place +sudo cp ghostunnel.socket ghostunnel.service /etc/systemd/system/ + +# Reload, enable, and start the socket +sudo systemctl daemon-reload +sudo systemctl enable --now ghostunnel.socket +``` + +systemd will start `ghostunnel.service` on demand when a connection arrives +on the socket. + +## Security Hardening + +systemd provides extensive [sandboxing options][systemd-exec] that complement +Ghostunnel's built-in Landlock support. The following settings restrict +the service to only the privileges it needs: + +```ini +[Service] +# Run as a dedicated unprivileged user +DynamicUser=yes + +# Filesystem restrictions +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible + +# Network: only allow AF_INET/AF_INET6 (and AF_UNIX for syslog/notify) +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# Capabilities: drop everything, Ghostunnel doesn't need any +# (use socket activation or listen on ports > 1024) +CapabilityBoundingSet= +NoNewPrivileges=yes + +# System call filter: allow only networking and basic I/O +SystemCallFilter=@system-service +SystemCallArchitectures=native + +# Misc +LockPersonality=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RemoveIPC=yes +UMask=0077 +``` + +### Notes + +* **`DynamicUser=yes`** allocates a transient user at runtime. If you need + persistent state or specific file ownership, use a static `User=ghostunnel` + instead. +* **`CapabilityBoundingSet=`** (empty) drops all capabilities. If you need to + bind to a privileged port (< 1024) without socket activation, add + `CAP_NET_BIND_SERVICE` instead. +* **`ProtectSystem=strict`** makes the entire filesystem read-only except + `/dev`, `/proc`, and `/sys`. Ghostunnel only needs to read certificate + files, so this is safe. If your certificates live outside the default + paths, no extra configuration is needed — they are already readable. +* These settings work alongside Ghostunnel's own Landlock sandboxing + (enabled by default on Linux). The two layers are complementary — systemd + restricts at the process level, Landlock restricts within the process. +* Run `systemd-analyze security ghostunnel.service` to audit the effective + security posture of your unit file. + +[sd-notify]: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +[systemd-service]: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html +[systemd-exec]: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html +[systemd-socket]: https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html diff --git a/docs/deployment/watchdog.md b/docs/deployment/watchdog.md deleted file mode 100644 index d6f5598db4..0000000000 --- a/docs/deployment/watchdog.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Systemd Watchdog -description: Integrate with the systemd watchdog timer for automatic restart on failure. -weight: 20 -aliases: - - /docs/watchdog/ ---- - -*Available since v1.8.0.* - -Ghostunnel supports systemd's [notify][sd-notify] and watchdog functionality on -Linux. This allows systemd to know when Ghostunnel is ready and to automatically -restart it if it becomes unresponsive. - -## How It Works - -When running as a [`Type=notify-reload`][systemd-service] service: - -* **Notify**: Ghostunnel signals readiness to systemd after it has successfully - loaded certificates and started listening. Systemd will not consider the - service "started" until this signal is received. -* **Watchdog**: Ghostunnel periodically sends a heartbeat to systemd at the - interval specified by `WatchdogSec`. If systemd does not receive a heartbeat - within the configured interval, it considers the process hung and takes the - action specified by `Restart` (typically restarting the service). -* **Reload**: When you run `systemctl reload ghostunnel`, systemd sends - `SIGHUP` to the process, which triggers a certificate reload (same as - sending `SIGHUP` manually). - -## Example Unit File - -```ini -[Unit] -Description=Ghostunnel -After=network.target - -[Service] -Type=notify-reload -ExecStart=/usr/bin/ghostunnel server \ - --listen=localhost:8443 \ - --target=localhost:8080 \ - --keystore=/etc/ghostunnel/server-keystore.p12 \ - --cacert=/etc/ghostunnel/cacert.pem \ - --allow-cn=client -WatchdogSec=5 -Restart=always - -[Install] -WantedBy=default.target -``` - -## Notes - -* `Type=notify-reload` requires systemd v253 or later. If you are on an older - version, use `Type=notify` instead (reload via `systemctl reload` will not - work, but you can still send `SIGHUP` manually). -* The `WatchdogSec` value should be set based on your tolerance for downtime. - A value of `5` (5 seconds) is a reasonable default. Very low values (e.g. `1`) - may cause spurious restarts under heavy load. -* Watchdog and notify functionality is only available on Linux. On other - platforms, use `Type=simple` and manage restarts via your service manager's - native mechanisms. -* For socket activation with systemd, see - [Socket Activation]({{< ref "socket-activation.md" >}}). - -[sd-notify]: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html -[systemd-service]: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html diff --git a/docs/deployment/windows-service.md b/docs/deployment/windows-service.md new file mode 100644 index 0000000000..df0bf9c0f3 --- /dev/null +++ b/docs/deployment/windows-service.md @@ -0,0 +1,104 @@ +--- +title: Windows Service +description: Install and manage Ghostunnel as a native Windows service via the Service Control Manager. +weight: 25 +--- + +Ghostunnel can run as a native Windows service managed by the +[Service Control Manager][scm] (SCM). The `ghostunnel service` subcommands +handle installation, removal, and lifecycle control. All service management +commands require **Administrator** privileges. + +## Installing a Service + +Use `ghostunnel service install` to register Ghostunnel with the SCM. Proxy +arguments (server or client mode flags) are passed after a `--` separator: + +```bat +ghostunnel service install -- server ^ + --listen localhost:8443 ^ + --target localhost:8080 ^ + --cert C:\certs\server-cert.pem ^ + --key C:\certs\server-key.pem ^ + --cacert C:\certs\cacert.pem ^ + --allow-cn client +``` + +This registers the service, sets it to start automatically on boot, and +immediately starts it. The service appears in the SCM with the display name +"Ghostunnel (``)". + +### Custom Service Name + +By default the service is named `ghostunnel`. Use `--service-name` to run +multiple instances with different configurations: + +```bat +ghostunnel service install --service-name ghostunnel-api -- server ^ + --listen localhost:8443 --target localhost:8080 ... + +ghostunnel service install --service-name ghostunnel-admin -- server ^ + --listen localhost:9443 --target localhost:9080 ... +``` + +Service names may contain letters, digits, hyphens, underscores, and spaces +(max 256 characters). + +## Managing a Service + +```bat +# Check service status +ghostunnel service status [--service-name NAME] + +# Start a stopped service +ghostunnel service start [--service-name NAME] + +# Stop a running service (graceful drain) +ghostunnel service stop [--service-name NAME] + +# Remove the service entirely +ghostunnel service uninstall [--service-name NAME] +``` + +Stopping a service triggers a graceful shutdown: Ghostunnel stops accepting +new connections and drains in-flight requests before exiting, the same as +sending `Ctrl-C` to an interactive process. + +Uninstall will refuse to remove a service that was not originally installed +by Ghostunnel (identified by a `GhostunnelManaged` registry marker). + +## Windows Event Log + +When running as a service, you typically want logs written to the +[Windows Event Log][eventlog] rather than stdout. Pass `--eventlog` in the +proxy arguments: + +```bat +ghostunnel service install -- server --eventlog ^ + --listen localhost:8443 --target localhost:8080 ... +``` + +Events are written under the source name matching the service name (default +`ghostunnel`). View them with Event Viewer or PowerShell: + +```powershell +Get-EventLog -LogName Application -Source ghostunnel -Newest 20 +``` + +The `--eventlog` flag also works when running Ghostunnel interactively +(outside of a service), though this is less common. + +## Subcommand Reference + +| Subcommand | Description | +|------------|-------------| +| `service install [--service-name NAME] -- ARGS...` | Register, configure, and start the service | +| `service uninstall [--service-name NAME]` | Stop and remove the service | +| `service start [--service-name NAME]` | Start an existing stopped service | +| `service stop [--service-name NAME]` | Gracefully stop a running service | +| `service status [--service-name NAME]` | Show the current service state | + +All subcommands default to `--service-name ghostunnel` if not specified. + +[scm]: https://learn.microsoft.com/en-us/windows/win32/services/service-control-manager +[eventlog]: https://learn.microsoft.com/en-us/windows/win32/eventlog/event-logging diff --git a/docs/getting-started/flags.md b/docs/getting-started/flags.md index 3da3d2b856..b06c38e1b1 100644 --- a/docs/getting-started/flags.md +++ b/docs/getting-started/flags.md @@ -24,7 +24,7 @@ supported file formats and chain ordering. | `--cert PATH` | Path to certificate (PEM with certificate chain). | | `--key PATH` | Path to certificate private key (PEM with private key). | | `--storepass PASS` | Password for keystore (if using PKCS12 keystore, optional). | -| `--cacert PATH` | Path to CA bundle file (PEM/X509). Uses system trust store by default. | +| `--cacert CACERT` | Path to CA bundle file (PEM/X509). Uses system trust store by default. | | `--use-workload-api` | Certificate and root CAs are retrieved via the SPIFFE Workload API. See [SPIFFE]({{< ref "spiffe-workload-api.md" >}}). | | `--use-workload-api-addr ADDR` | Retrieve certificates and root CAs via the SPIFFE Workload API at the specified address (implies `--use-workload-api`). See [SPIFFE]({{< ref "spiffe-workload-api.md" >}}). | @@ -102,8 +102,8 @@ Flags specific to `ghostunnel server`. ### Required -See [Socket Activation]({{< ref "socket-activation.md" >}}) for `systemd:NAME` and -`launchd:NAME` addresses. +See [Systemd]({{< ref "systemd.md" >}}) and [Launchd]({{< ref "launchd.md" >}}) +for `systemd:NAME` and `launchd:NAME` addresses. | Flag | Description | |------|-------------| @@ -152,8 +152,8 @@ See [Access Control Flags]({{< ref "access-flags.md" >}}) for OPA/Rego policy de | Flag | Description | |------|-------------| -| `--allow-policy BUNDLE` | Location of an OPA policy bundle. | -| `--allow-query QUERY` | Rego query to validate against the client certificate and the policy. | +| `--allow-policy BUNDLE` | Location of an OPA policy bundle. Mutually exclusive with other access control flags. | +| `--allow-query QUERY` | Rego query to validate against the client certificate and the policy. Must be used with `--allow-policy`. | ## Client Mode Flags @@ -161,8 +161,8 @@ Flags specific to `ghostunnel client`. ### Required -See [Socket Activation]({{< ref "socket-activation.md" >}}) for `systemd:NAME` and -`launchd:NAME` addresses. +See [Systemd]({{< ref "systemd.md" >}}) and [Launchd]({{< ref "launchd.md" >}}) +for `systemd:NAME` and `launchd:NAME` addresses. | Flag | Description | |------|-------------| diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 93e21ee205..fc52f50592 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -137,4 +137,4 @@ forwarded the plaintext request to the backend. - [ACME Support]({{< ref "acme.md" >}}): automatic certificates from Let's Encrypt - [Metrics & Profiling]({{< ref "metrics.md" >}}): status port, Prometheus metrics, pprof - [PROXY Protocol]({{< ref "proxy-protocol.md" >}}): pass client connection metadata to backends -- [Socket Activation]({{< ref "socket-activation.md" >}}) and [Systemd Watchdog]({{< ref "watchdog.md" >}}): run Ghostunnel as a service +- [Systemd]({{< ref "systemd.md" >}}), [Launchd]({{< ref "launchd.md" >}}), and [Windows Service]({{< ref "windows-service.md" >}}): run Ghostunnel as a service diff --git a/docs/networking/_index.md b/docs/networking/_index.md index 0ea13f2e0c..9260dfa58a 100644 --- a/docs/networking/_index.md +++ b/docs/networking/_index.md @@ -1,6 +1,6 @@ --- title: Networking & Integration -description: Connection metadata, socket activation, graceful draining, and metrics. +description: PROXY protocol, graceful draining, and metrics. weight: 40 --- diff --git a/docs/networking/graceful-shutdown.md b/docs/networking/graceful-shutdown.md index c0ba1815c3..9a249d0ea3 100644 --- a/docs/networking/graceful-shutdown.md +++ b/docs/networking/graceful-shutdown.md @@ -101,5 +101,5 @@ connection behavior and may be relevant when tuning shutdown. See When running as a systemd service with `Type=notify-reload`, Ghostunnel notifies systemd of its state transitions (ready, reloading, stopping). The graceful shutdown sequence integrates naturally with systemd's service -lifecycle. See [Systemd Watchdog]({{< ref "watchdog.md" >}}) for unit file -examples and configuration details. +lifecycle. See [Systemd]({{< ref "systemd.md" >}}) for unit file examples and +configuration details. diff --git a/docs/networking/socket-activation.md b/docs/networking/socket-activation.md deleted file mode 100644 index 1391f97d29..0000000000 --- a/docs/networking/socket-activation.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -title: Socket Activation -description: Use systemd (Linux) or launchd (macOS) socket activation for on-demand startup. -weight: 20 -aliases: - - /docs/socket-activation/ ---- - -Ghostunnel supports socket activation via both systemd (on Linux) and launchd -(on macOS). Socket activation is supported for the `--listen` and `--status` -flags, and can be used by passing an address of the form `systemd:` or -`launchd:`, where `` should be the name of the socket as defined in -your systemd/launchd configuration. - -Note that socket activation is not available on Windows. - -## launchd - -See Apple's [Creating Launch Daemons and Agents][launchd-guide] for background -on launchd plists. - -A launchd plist to launch Ghostunnel in server mode on :8081, -listening for status connections on :8082, and forwarding connections to :8083 -could look like this: - -```xml - - - - - Label - com.square.ghostunnel - ProgramArguments - - /usr/bin/ghostunnel - server - --keystore - /etc/ghostunnel/server-keystore.p12 - --cacert - /etc/ghostunnel/cacert.pem - --target - localhost:8083 - --listen - launchd:Listener - --status - launchd:Status - --allow-cn - client - - StandardOutPath - /var/log/ghostunnel.out.log - StandardErrorPath - /var/log/ghostunnel.err.log - Sockets - - Listener - - SockServiceName - 8081 - SockType - stream - SockFamily - IPv4 - - Status - - SockServiceName - 8082 - SockType - stream - SockFamily - IPv4 - - - - -``` - -Note that in the launchd case *both* `SockType` and `SockFamily` need to be -defined for each socket. If for example the family were to be left out, launchd -would open two sockets (IPv4 and IPv6) for the given key (like `Listener`) and -pass them to Ghostunnel which is not currently supported. - -To install and enable: - -```bash -# Copy the plist into place -sudo cp com.square.ghostunnel.plist /Library/LaunchDaemons/ - -# Load and start -sudo launchctl load /Library/LaunchDaemons/com.square.ghostunnel.plist - -# Stop and unload -sudo launchctl unload /Library/LaunchDaemons/com.square.ghostunnel.plist -``` - -Use `~/Library/LaunchAgents/` instead of `/Library/LaunchDaemons/` if running -as a user agent rather than a system daemon. - -## systemd - -See the [`systemd.socket`][systemd-socket] man page for the full socket unit -reference. - -A systemd unit for a `ghostunnel.socket` for listening on `*:8443` could look -like this: - -```ini -[Unit] -Description=Ghostunnel Socket -PartOf=ghostunnel.service - -[Socket] -FileDescriptorName=ghostunnel -ListenStream=0.0.0.0:8443 - -[Install] -WantedBy=sockets.target -``` - -A corresponding `ghostunnel.service` to forward to `localhost:8080` could look -like this: - -```ini -[Unit] -Description=Ghostunnel -After=network.target ghostunnel.socket -Requires=ghostunnel.socket - -[Service] -Type=simple -ExecStart=/usr/bin/ghostunnel server --listen=systemd:ghostunnel --target=localhost:8080 --keystore=/etc/ghostunnel/server-keystore.p12 --cacert=/etc/ghostunnel/cacert.pem --allow-cn=client - -[Install] -WantedBy=default.target -``` - -Note that the `FileDescriptorName` in `ghostunnel.socket` matches the name passed to -`--listen`. If multiple sockets are needed, e.g. for a status port, the name can be -used to distinguish the listening and status sockets. - -To install and enable: - -```bash -# Copy unit files into place -sudo cp ghostunnel.socket ghostunnel.service /etc/systemd/system/ - -# Reload, enable, and start the socket -sudo systemctl daemon-reload -sudo systemctl enable --now ghostunnel.socket -``` - -systemd will start `ghostunnel.service` on demand when a connection arrives -on the socket. - -Ghostunnel also supports systemd notify and watchdog functionality. See -[WATCHDOG]({{< ref "watchdog.md" >}}) for details on configuring `Type=notify-reload` services. - -[launchd-guide]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html -[systemd-socket]: https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html diff --git a/docs/security/access-flags.md b/docs/security/access-flags.md index 131c7ddc35..4239527b4c 100644 --- a/docs/security/access-flags.md +++ b/docs/security/access-flags.md @@ -150,9 +150,12 @@ but the backend doesn't require mutual authentication. Ghostunnel has support for [Open Policy Agent][opa] (OPA), both in server and client mode. The policy must be provided as an [OPA bundle][opa-bundles] on -disk and the use of OPA is mutually exclusive with any other `allow` (or -`verify`) flags. Policy bundles can be reloaded at runtime much like -certificates, with the `--timed-reload` flag or via `SIGHUP`. +disk. In server mode, the use of OPA is mutually exclusive with other access +control flags (`--allow-cn`, `--allow-ou`, `--allow-dns`, `--allow-uri`, +`--allow-all`, and `--disable-authentication`). In client mode, +`--verify-policy`/`--verify-query` can be combined with other `verify` flags. +Policy bundles can be reloaded at runtime +much like certificates, with the `--timed-reload` flag or via `SIGHUP`. [opa]: https://www.openpolicyagent.org/ [opa-bundles]: https://www.openpolicyagent.org/docs/latest/management-bundles/ From 5af9ec69e4351999464acc33cdfd97b9b77e54a7 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Thu, 23 Apr 2026 20:42:32 -0700 Subject: [PATCH 08/23] Proof-read and improve documentation --- docs/_index.md | 9 -- docs/certificates/acme.md | 2 +- docs/certificates/hsm-pkcs11.md | 36 +++--- docs/deployment/_index.md | 5 +- docs/deployment/systemd.md | 6 +- docs/getting-started/_index.md | 2 - docs/getting-started/flags.md | 3 +- docs/getting-started/quickstart.md | 4 +- docs/networking/graceful-shutdown.md | 5 +- docs/networking/metrics.md | 22 ++-- docs/networking/proxy-protocol.md | 13 +- docs/reference/_index.md | 6 +- docs/security/access-flags.md | 151 ++++++++++-------------- docs/security/general.md | 2 +- docs/spiffe-workload-api-demo/README.md | 6 +- magefile.go | 20 ++-- website/content/_index.md | 28 ++--- 17 files changed, 135 insertions(+), 185 deletions(-) diff --git a/docs/_index.md b/docs/_index.md index a1423edf7d..389444336d 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -3,12 +3,3 @@ title: Documentation description: Ghostunnel documentation weight: 10 --- - -Documentation for Ghostunnel, organized into the following sections: - -- **Getting Started** — install Ghostunnel, get an mTLS tunnel running, and find the right flag. -- **Certificates & Identity** — certificate formats, ACME, SPIFFE, PKCS#11, and OS keychains. -- **Security & Access Control** — TLS protocol settings and rules that decide who can connect. -- **Networking & Integration** — PROXY protocol, graceful shutdown, and metrics. -- **Deployment & Operations** — Docker images, systemd, launchd, and Windows service integration. -- **Reference** — generated man pages for each supported platform. diff --git a/docs/certificates/acme.md b/docs/certificates/acme.md index 17513f4ce8..0e6c4f7ed2 100644 --- a/docs/certificates/acme.md +++ b/docs/certificates/acme.md @@ -15,7 +15,7 @@ stapling. To enable ACME, use the `--auto-acme-cert` flag with the FQDN to obtain a certificate for. You must also specify an email address with -`--auto-acme-email` (for CA notifications about certificate lifecycle events) +`--auto-acme-email` (used by the CA for expiration and renewal notices) and agree to the CA's Terms of Service with `--auto-acme-agree-to-tos`: ```bash diff --git a/docs/certificates/hsm-pkcs11.md b/docs/certificates/hsm-pkcs11.md index eb3172292a..eaba22773a 100644 --- a/docs/certificates/hsm-pkcs11.md +++ b/docs/certificates/hsm-pkcs11.md @@ -6,12 +6,9 @@ aliases: - /docs/hsm-pkcs11/ --- -Ghostunnel has support for loading private keys from [PKCS#11][pkcs11-spec] -modules, which should work with any hardware security module that exposes a -PKCS#11 interface. -An easy way to test the PKCS#11 interface for development purposes is with -[SoftHSM][softhsm]. Note that CGO is required in order for PKCS#11 support to -work. +Ghostunnel can load private keys from any hardware security module that +exposes a [PKCS#11][pkcs11-spec] interface. CGO is required for PKCS#11 +support. [SoftHSM][softhsm] is an easy way to test without real hardware. [softhsm]: https://github.com/opendnssec/SoftHSMv2 @@ -46,16 +43,15 @@ ghostunnel server \ --allow-cn client ``` -The `--pkcs11-module`, `--pkcs11-token-label` and `--pkcs11-pin` flags can be -used to select the private key to be used from the PKCS#11 module. It's also possible -to use environment variables to set PKCS#11 options instead of flags (via -`PKCS11_MODULE`, `PKCS11_TOKEN_LABEL` and `PKCS11_PIN`), useful if you don't want to show the PIN on the command line. +The `--pkcs11-module`, `--pkcs11-token-label`, and `--pkcs11-pin` flags +select the private key from the PKCS#11 module. You can also set these via +environment variables (`PKCS11_MODULE`, `PKCS11_TOKEN_LABEL`, `PKCS11_PIN`) +to keep the PIN off the command line. -Note that `--cert` needs to point to the certificate chain that corresponds -to the private key in the PKCS#11 module, with the leaf certificate being the -first certificate in the chain (see -[Certificate Formats]({{< ref "formats.md" >}})). Ghostunnel currently -cannot read the certificate chain directly from the module. +Note: `--cert` must point to the certificate chain matching the private key in +the PKCS#11 module, with the leaf certificate first (see [Certificate +Formats]({{< ref "formats.md" >}})). Ghostunnel cannot read the certificate +chain from the module directly. ## Using a YubiKey @@ -181,9 +177,8 @@ pkcs11-tool --module /path/to/libykcs11.dylib -O ## Certificate Hotswapping When using PKCS#11, certificate hotswapping (via `SIGHUP`/`SIGUSR1` or -`--timed-reload`) reloads only the certificate from disk. The private key -in the HSM stays put, so the new certificate still needs to match the key -that was loaded from the HSM. +`--timed-reload`) reloads only the certificate from disk. The private key in +the HSM is not reloaded, so the new certificate must still match it. Note that Landlock sandboxing is automatically disabled when PKCS#11 is used, as PKCS#11 modules are opaque shared libraries that may need access to @@ -191,9 +186,8 @@ arbitrary files and sockets. ## Inspecting PKCS#11 State -If you need to inspect the state of a PKCS#11 module/token, we recommend the -[`pkcs11-tool`][pkcs11-tool] utility from OpenSC. For example, it can be used -to list slots or read certificate(s) from a module: +Use the [`pkcs11-tool`][pkcs11-tool] utility from OpenSC to inspect PKCS#11 +module/token state. For example: ```bash # List slots on a module diff --git a/docs/deployment/_index.md b/docs/deployment/_index.md index 37fcb37c38..8cf9039a64 100644 --- a/docs/deployment/_index.md +++ b/docs/deployment/_index.md @@ -4,6 +4,5 @@ description: Running Ghostunnel as a container or as a supervised system service weight: 50 --- -Published container images and integration with platform service managers -(systemd, launchd, Windows SCM) for running Ghostunnel as a long-lived -service. +Container images and service manager integration (systemd, launchd, Windows +SCM) for running Ghostunnel as a long-lived service. diff --git a/docs/deployment/systemd.md b/docs/deployment/systemd.md index 41e2d97e10..1db96db647 100644 --- a/docs/deployment/systemd.md +++ b/docs/deployment/systemd.md @@ -156,9 +156,9 @@ on the socket. ## Security Hardening -systemd provides extensive [sandboxing options][systemd-exec] that complement -Ghostunnel's built-in Landlock support. The following settings restrict -the service to only the privileges it needs: +systemd provides [sandboxing options][systemd-exec] that complement +Ghostunnel's built-in Landlock support. These settings restrict the service to +the privileges it needs: ```ini [Service] diff --git a/docs/getting-started/_index.md b/docs/getting-started/_index.md index df25ab17fd..f7bd6d6e98 100644 --- a/docs/getting-started/_index.md +++ b/docs/getting-started/_index.md @@ -3,5 +3,3 @@ title: Getting Started description: Quick start guide and flag overview. weight: 10 --- - -Quick start guide and flag overview. diff --git a/docs/getting-started/flags.md b/docs/getting-started/flags.md index b06c38e1b1..72c4dbb572 100644 --- a/docs/getting-started/flags.md +++ b/docs/getting-started/flags.md @@ -6,8 +6,7 @@ aliases: - /docs/flags/ --- -Quick reference for all Ghostunnel command-line flags, grouped by mode. For -detailed usage of specific features, see the linked documentation pages. +For detailed usage of specific features, see the linked documentation pages. ## Global Flags diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index fc52f50592..f64a432a7b 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -32,8 +32,8 @@ go tool mage go:build ## Generate Test Certificates If you already maintain a PKI, you can skip this step and use your existing -certificates. The steps below are for generating test certificates for -testing and development purposes only. +certificates. The steps below generate certificates for +testing and development only. You need a CA, a server certificate, and a client certificate. The rest of this guide uses the paths from `test-keys/`, so adjust if you use a different diff --git a/docs/networking/graceful-shutdown.md b/docs/networking/graceful-shutdown.md index 9a249d0ea3..299fd7dd3c 100644 --- a/docs/networking/graceful-shutdown.md +++ b/docs/networking/graceful-shutdown.md @@ -77,8 +77,7 @@ See [Command-Line Flags]({{< ref "flags.md" >}}) for the full flag reference. ## Choosing a Shutdown Timeout -The default timeout of 5 minutes is deliberately generous. Consider your -workload when tuning this value: +The default timeout of 5 minutes is generous. Tune it based on your workload: - **Short-lived requests** (e.g. REST APIs): a lower timeout like `30s` or `1m` is usually sufficient. @@ -100,6 +99,6 @@ connection behavior and may be relevant when tuning shutdown. See When running as a systemd service with `Type=notify-reload`, Ghostunnel notifies systemd of its state transitions (ready, reloading, stopping). The -graceful shutdown sequence integrates naturally with systemd's service +graceful shutdown sequence integrates with systemd's service lifecycle. See [Systemd]({{< ref "systemd.md" >}}) for unit file examples and configuration details. diff --git a/docs/networking/metrics.md b/docs/networking/metrics.md index d5e5b348e9..0851622eed 100644 --- a/docs/networking/metrics.md +++ b/docs/networking/metrics.md @@ -6,10 +6,9 @@ aliases: - /docs/metrics/ --- -Ghostunnel has a notion of "status port", a TCP port (or UNIX socket) that can -be used to expose status and metrics information over HTTPS. The status port -feature can be controlled via the `--status` flag. Profiling endpoints on the -status port can be enabled with `--enable-pprof`. +Ghostunnel provides a status port, a TCP port (or UNIX socket) that +exposes status and metrics over HTTP(S). Enable it with `--status`. Profiling +endpoints can be added with `--enable-pprof`. The X.509 certificate on the status port will be the same as the certificate used for proxying (either the client or server certificate). This means you can @@ -27,9 +26,9 @@ ghostunnel client \ --status localhost:6060 ``` -Note that we set the status port to "localhost:6060". Ghostunnel will start an -internal HTTPS server and listen for connections on the given host/port. You -can also specify a UNIX socket instead of a TCP port. +Here the status port is set to `localhost:6060`. Ghostunnel starts an internal +HTTPS server on that address. You can also specify a UNIX socket instead of a +TCP port. How to check status and read connection metrics: @@ -54,11 +53,10 @@ curl --cacert test-keys/cacert.pem 'https://localhost:6060/debug/pprof/goroutine go tool pprof -seconds 5 https+insecure://localhost:6060/debug/pprof/profile ``` -Note that `go tool pprof` does not support setting CA certificates at the -moment, hence the use of the `https+insecure` scheme in the last example. You -can use the standard `https` scheme if your Ghostunnel is using a certificate -trusted by your system (see [golang/go#20939][pprof-bug]). For more -information on profiling via pprof, see the [`runtime/pprof`][pprof] and +Note: `go tool pprof` does not support custom CA certificates, so the example +above uses `https+insecure`. Use the standard `https` scheme if Ghostunnel's +certificate is trusted by your system (see [golang/go#20939][pprof-bug]). For +more on pprof, see the [`runtime/pprof`][pprof] and [`net/http/pprof`][http-pprof] docs. [pprof]: https://pkg.go.dev/runtime/pprof diff --git a/docs/networking/proxy-protocol.md b/docs/networking/proxy-protocol.md index ff2ce7b926..a0df863ae8 100644 --- a/docs/networking/proxy-protocol.md +++ b/docs/networking/proxy-protocol.md @@ -7,12 +7,11 @@ aliases: --- When Ghostunnel terminates TLS, the backend only sees a plaintext connection -from Ghostunnel itself. It has no idea who the original client was, what TLS +from Ghostunnel itself -- it does not know who the original client was, what TLS version was negotiated, or whether a client certificate was presented. The PROXY -protocol fixes this: Ghostunnel prepends a small binary header to each -forwarded connection carrying the original client metadata. Backends can then -do logging, access control, or auditing based on client identity without -needing their own TLS stack. +protocol fixes this: Ghostunnel prepends a binary header to each forwarded +connection with the original client metadata. Backends can then log, enforce +access control, or audit based on client identity without their own TLS stack. ## Enabling @@ -125,8 +124,8 @@ frameworks support this: - **HAProxy**: `accept-proxy` on `bind` lines - **Custom apps**: use a PROXY protocol parsing library for your language -Backends that aren't expecting PROXY protocol will see the binary header as -garbage at the start of the stream and will reject the connection. +Backends that do not expect PROXY protocol will see the binary header as +garbage and reject the connection. ## References diff --git a/docs/reference/_index.md b/docs/reference/_index.md index 1a4de92fab..8a053da5a7 100644 --- a/docs/reference/_index.md +++ b/docs/reference/_index.md @@ -4,6 +4,6 @@ description: Platform-specific man pages with every flag and mode documented. weight: 60 --- -Generated man pages for each supported platform. These duplicate some material -from [Command-Line Flags]({{< ref "flags.md" >}}) but include every flag and -sub-command in exhaustive form. +Generated man pages for each supported platform. These overlap with +[Command-Line Flags]({{< ref "flags.md" >}}) but cover every flag and +sub-command. diff --git a/docs/security/access-flags.md b/docs/security/access-flags.md index 4239527b4c..21c58a9be3 100644 --- a/docs/security/access-flags.md +++ b/docs/security/access-flags.md @@ -6,51 +6,42 @@ aliases: - /docs/access-flags/ --- -Ghostunnel uses TLS with mutual authentication for authentication and access -control. This means that both the client and server present a certificate that -can be verified by the other party. +Ghostunnel uses mutual TLS for authentication and access control. Both the +client and server present a certificate that the other party verifies. ## Server mode -There are several flags available to restrict which clients can connect to a -Ghostunnel server, based on checks on the client certificate. +Several flags restrict which clients can connect, based on fields in the +client certificate. -Access control flags in server mode are treated as a logical disjunction (OR) -when multiple flags are specified. This means that a client will be allowed to -complete a connection as long as at least one flag matches. +When multiple access control flags are specified, they are OR'd together: a +client is allowed if at least one flag matches. * `--allow-all` -Setting this flag allows all clients with a valid certificate, regardless of -the client certificate subject. This flag is mutually exclusive with other -access control flags. +Allow all clients with a valid certificate, regardless of the certificate +subject. Mutually exclusive with other access control flags. * `--allow-cn` -Allow clients with given common name (CN) in the subject. Can be repeated to -allow multiple clients with different CNs to connect. Performs an exact string -comparison on the CN field. +Allow clients with the given common name (CN). Can be repeated. Performs an +exact string match. * `--allow-ou` -Allow clients with given organizational unit (OU) field in the subject. Can be -repeated to allow multiple clients with different OUs to connect. Performs an -exact string comparison on the OU field. +Allow clients with the given organizational unit (OU). Can be repeated. +Performs an exact string match. * `--allow-dns` -Allow clients with given DNS subject alternative name (DNS SAN) on the -certificate. Can be repeated to allow multiple clients with different DNS SANs -to connect. Note that this performs the access check based on a comparison of -the DNS SAN value of the client certificate, it does not perform any DNS -lookups. +Allow clients with the given DNS subject alternative name (DNS SAN). Can be +repeated. Matches the DNS SAN value on the certificate, no DNS lookups are +performed. * `--allow-uri` -Allow clients with given URI subject alternative name (URI SAN) on the -certificate. Can be repeated to allow multiple clients with different URI SANs -to connect. This flag may also contain `*` and `**` wildcards that can be used -to match multiple clients. +Allow clients with the given URI subject alternative name (URI SAN). Can be +repeated. Supports `*` and `**` wildcards. For example, setting `--allow-uri=spiffe://ghostunnel/*` would allow clients with `spiffe://ghostunnel/client1` or `spiffe://ghostunnel/client2` URI SANs (as @@ -64,10 +55,8 @@ For more information, see the Open Policy Agent section below. * `--disable-authentication` -Disables client authentication entirely, no client certificate will be required -from any client. This means that anyone will be able to establish a connection -to the Ghostunnel server. This flag is mutually exclusive with other access -control flags. +Disable client authentication entirely, no client certificate is required. +Anyone can connect. Mutually exclusive with other access control flags. ### Passing Client Identity to Backends @@ -80,50 +69,40 @@ extensions. ## Client mode -Ghostunnel in client mode offers various flags that can be used to augment and -perform additional checks on servers it connects to. Regardless of flags passed -to the client, it will always perform standard hostname verification to check -the hostname against the server certificate. +In client mode, additional flags can verify properties of the server +certificate. Standard hostname verification always runs regardless of which +flags are set. -Access control flags in client mode are treated as a logical disjunction (OR) -when multiple flags are specified. This means that a connection to the server -will be allowed as long as at least one flag matches, assuming that hostname -verification was also successful. +When multiple verification flags are specified, they are OR'd together: a +connection is allowed if at least one flag matches (and hostname verification +passes). * `--override-server-name` -If set, overrides the server name used for hostname verification to be -different from the hostname that was passed in `--target`. This also sets the -hostname passed to the backend for SNI purposes. The logic for hostname -verification is implemented as part of the [crypto/tls][tls] package in Go's -standard library, see the `ServerName` field on the `tls.Config` struct. +Override the server name used for hostname verification and SNI, instead of +using the hostname from `--target`. See the `ServerName` field on +[`tls.Config`][tls]. * `--verify-cn` -Verify the common name (CN) of the server certificate, on top of the hostname. -Can be repeated to check that at least one of a set of CNs is present. This -performs an exact string comparison on the CN field of the certificate. +Verify the common name (CN) of the server certificate, in addition to +hostname verification. Can be repeated. Performs an exact string match. * `--verify-ou` -Verify the organizational unit (OU) of the server certificate, on top of the -hostname. Can be repeated to check that at least one of a set of OUs is -present. This performs an exact string comparison on the OU field of the -certificate. +Verify the organizational unit (OU) of the server certificate, in addition to +hostname verification. Can be repeated. Performs an exact string match. * `--verify-dns` -Verify the presence of a DNS subject alternative name (DNS SAN) on the server -certificate, on top of the hostname. This checks that the given DNS name is -listed as a valid name on the certificate. Can be repeated to require -that at least one of a set of hostnames is present. +Verify that a DNS subject alternative name (DNS SAN) is present on the server +certificate, in addition to hostname verification. Can be repeated. * `--verify-uri` -Verify the presence of a URI subject alternative name (URI SAN) on the server -certificate, on top of the hostname. This checks that the given URI name is -listed as a valid name on the certificate. This flag may also contain `*` and -`**` wildcards that can be used to match multiple servers. +Verify that a URI subject alternative name (URI SAN) is present on the server +certificate, in addition to hostname verification. Supports `*` and `**` +wildcards. For example, setting `--verify-uri=spiffe://ghostunnel/*` would allow servers with `spiffe://ghostunnel/server1` or `spiffe://ghostunnel/server2` URI SANs (as @@ -137,9 +116,8 @@ For more information, see the Open Policy Agent section below. * `--disable-authentication` -Disable client authentication, no certificate will be provided to the server. -This is useful if you just want to use Ghostunnel to wrap a connection in TLS -but the backend doesn't require mutual authentication. +Disable client authentication, no certificate is sent to the server. Useful +when the backend does not require mutual TLS. [tls]: https://pkg.go.dev/crypto/tls [wildcard]: https://pkg.go.dev/github.com/ghostunnel/ghostunnel/wildcard @@ -148,14 +126,11 @@ but the backend doesn't require mutual authentication. *Available since v1.7.0, OPA bundle support available since v1.9.0.* -Ghostunnel has support for [Open Policy Agent][opa] (OPA), both in server and -client mode. The policy must be provided as an [OPA bundle][opa-bundles] on -disk. In server mode, the use of OPA is mutually exclusive with other access -control flags (`--allow-cn`, `--allow-ou`, `--allow-dns`, `--allow-uri`, -`--allow-all`, and `--disable-authentication`). In client mode, -`--verify-policy`/`--verify-query` can be combined with other `verify` flags. -Policy bundles can be reloaded at runtime -much like certificates, with the `--timed-reload` flag or via `SIGHUP`. +Ghostunnel supports [Open Policy Agent][opa] (OPA) in both server and client +mode. The policy must be an [OPA bundle][opa-bundles] on disk. When using OPA, +we recommend expressing all access control logic in the policy itself and not +combining it with other access control flags. Policy bundles reload at runtime +via `--timed-reload` or `SIGHUP`, just like certificates. [opa]: https://www.openpolicyagent.org/ [opa-bundles]: https://www.openpolicyagent.org/docs/latest/management-bundles/ @@ -183,10 +158,9 @@ Example: ghostunnel client [...] --verify-policy=bundle.tar.gz --verify-query=data.policy.allow ``` -Inside your policy, you can access the reflected X.509 peer certificate using -`input.certificate`. For example, the policy below verifies that the presented -client certificate contains at least one of the allowed common names or SPIFFE -IDs. +Inside your policy, the peer's X.509 certificate is available as +`input.certificate`. The example below checks whether the client certificate +contains an allowed common name or SPIFFE ID. You can use the [Rego Playground](https://play.openpolicyagent.org) to test and develop policies. See the documentation for [x509.Certificate](https://pkg.go.dev/crypto/x509#Certificate) @@ -238,27 +212,24 @@ allow if { } ``` -The corresponding query for this policy is `data.policy.allow`, because we -want to determine the outcome of the policy by looking at `allow`. +The corresponding query for this policy is `data.policy.allow`. -See the documentation about [Golang's x509.Certificate -struct](https://pkg.go.dev/crypto/x509#Certificate) for more about other -properties you can match on, and the [Rego -documentation](https://www.openpolicyagent.org/docs/latest/policy-language/) -for more about the policy language. +See [x509.Certificate](https://pkg.go.dev/crypto/x509#Certificate) for all +available fields, and the +[Rego documentation](https://www.openpolicyagent.org/docs/latest/policy-language/) +for the policy language reference. ### Notes -* There is no mechanism to load a policy bundle from a remote OPA server. The policy - bundle has to be local, or be retrieved and stored locally out of band by a - different process. -* Older versions of Ghostunnel allowed specifying a Rego file rather than a - bundle as an argument to the `--allow-policy` and `--verify-policy` flags. This - still works, but the policy will be treated as a V0 policy for backward - compatibility. It's recommended to specify a bundle so you can set the language - version directly in the bundle manifest. -* By standard OPA convention, we consider a policy to be "allowed" if the query - is exactly one result with exactly one element that has the value `true`. +* Policy bundles must be local files. There is no built-in support for loading + from a remote OPA server. Fetch and store bundles locally using a separate + process. +* Passing a raw `.rego` file instead of a bundle to `--allow-policy` or + `--verify-policy` still works for backward compatibility (treated as V0). + Using a bundle is recommended so you can set the Rego language version in + the bundle manifest. +* By OPA convention, a policy is considered "allowed" if the query produces + exactly one result with a single element whose value is `true`. * Policy evaluation timeout is the same as the connection timeout. If a policy takes more time to execute than the specified connection timeout, the connection will fail. diff --git a/docs/security/general.md b/docs/security/general.md index 5b77489fbe..4e14670cc9 100644 --- a/docs/security/general.md +++ b/docs/security/general.md @@ -4,7 +4,7 @@ description: Landlock sandboxing, TLS protocol settings, cipher suites, address weight: 10 --- -An overview of Ghostunnel's TLS settings, and some info on Landlock sandboxing. +Ghostunnel's TLS settings and Landlock sandboxing. ## TLS Configuration diff --git a/docs/spiffe-workload-api-demo/README.md b/docs/spiffe-workload-api-demo/README.md index 7fe2f10684..584a7c094c 100644 --- a/docs/spiffe-workload-api-demo/README.md +++ b/docs/spiffe-workload-api-demo/README.md @@ -1,8 +1,8 @@ # SPIFFE Workload API Support Demo -The following demonstrates using the SPIFFE Workload API to supply Ghostunnel -with an X.509 identity and trusted CA roots that are used to facilitate -communication between a frontend and backend service. +This demo shows Ghostunnel using the SPIFFE Workload API to obtain X.509 +identities and trusted CA roots for mutual TLS between a frontend and backend +service. ## Prerequisites diff --git a/magefile.go b/magefile.go index da0e9f7b0c..7af54fac06 100644 --- a/magefile.go +++ b/magefile.go @@ -85,8 +85,8 @@ func (Go) Lint(ctx context.Context) error { } // Man generates the Ghostunnel man page from the built binary. -// Also generates docs/MANPAGE-.md from the man page using pandoc, -// with Hugo front matter prepended for the website. +// Also generates docs/reference/manpage-.md from the man page using +// pandoc, with Hugo front matter prepended for the website. func (Go) Man(ctx context.Context) error { mg.CtxDeps(ctx, Go.Build) @@ -99,26 +99,28 @@ func (Go) Man(ctx context.Context) error { return fmt.Errorf("failed to write ghostunnel.man: %w", err) } - // Generate docs/MANPAGE-.md from the man page using pandoc - manpageMD := fmt.Sprintf("docs/MANPAGE-%s.md", runtime.GOOS) + // Generate docs/reference/manpage-.md from the man page using pandoc + manpageMD := fmt.Sprintf("docs/reference/manpage-%s.md", runtime.GOOS) pandocOutput, err := sh.Output("pandoc", "-f", "man", "-t", "gfm", "ghostunnel.man") if err != nil { return fmt.Errorf("failed to convert man page to markdown: %w", err) } - // Platform-specific titles and weights for Hugo front matter + // Platform-specific titles, weights, and aliases for Hugo front matter manPageMeta := map[string]struct { title string weight int + alias string }{ - "darwin": {title: "Man Page (macOS)", weight: 91}, - "linux": {title: "Man Page (Linux)", weight: 90}, + "darwin": {title: "Man Page (macOS)", weight: 20, alias: "/docs/manpage-darwin/"}, + "linux": {title: "Man Page (Linux)", weight: 10, alias: "/docs/manpage-linux/"}, } meta, ok := manPageMeta[runtime.GOOS] if !ok { meta.title = fmt.Sprintf("Man Page (%s)", runtime.GOOS) - meta.weight = 92 + meta.weight = 30 + meta.alias = fmt.Sprintf("/docs/manpage-%s/", runtime.GOOS) } var buf bytes.Buffer @@ -126,6 +128,8 @@ func (Go) Man(ctx context.Context) error { fmt.Fprintf(&buf, "title: %s\n", meta.title) fmt.Fprintf(&buf, "description: Complete command-line reference with all flags, modes, and examples.\n") fmt.Fprintf(&buf, "weight: %d\n", meta.weight) + fmt.Fprintf(&buf, "aliases:\n") + fmt.Fprintf(&buf, " - %s\n", meta.alias) fmt.Fprintf(&buf, "---\n\n") fmt.Fprintf(&buf, "> This man page was generated from the %s binary. Some flags may differ on other platforms.\n\n", meta.title[len("Man Page ("):len(meta.title)-1]) buf.WriteString(pandocOutput) diff --git a/website/content/_index.md b/website/content/_index.md index 67106ab8b1..4b7b68118d 100644 --- a/website/content/_index.md +++ b/website/content/_index.md @@ -3,14 +3,13 @@ title: Ghostunnel description: A simple TLS proxy with mutual authentication support --- -Ghostunnel is a simple TLS proxy with mutual authentication support for -securing non-TLS backend applications. Ghostunnel supports two modes, **client -mode** and **server mode**. Ghostunnel in server mode runs in front of a -backend server and accepts TLS-secured connections, which are then proxied to -the (insecure) backend. Ghostunnel in client mode accepts (insecure) -connections through a TCP or UNIX domain socket and proxies them to a -TLS-secured service. A backend can be a TCP domain/port or a UNIX domain -socket. +Ghostunnel is a TLS proxy with mutual authentication support for securing +non-TLS services. It runs in one of two modes: + +* **Server mode**: accepts TLS connections and forwards them as plaintext to a + backend. +* **Client mode**: accepts plaintext connections on a TCP or UNIX socket and + forwards them over TLS to a remote service. ## Key Features @@ -31,8 +30,8 @@ socket. * **Metrics & Profiling**: Built-in status port with JSON and Prometheus metrics endpoints, plus optional pprof profiling. -Ghostunnel also supports UNIX domain sockets, PROXY protocol v2, systemd/launchd -socket activation, and more. See the [documentation](docs/) for details. +Ghostunnel also supports PROXY protocol v2, systemd/launchd socket activation, +and more. See the [documentation](docs/) for details. ## Getting Started @@ -42,11 +41,10 @@ available under [Docs](/docs/). ## Supported Platforms -Ghostunnel is developed primarily for Linux and macOS, although it should run -on any UNIX system that exposes `SO_REUSEPORT`, including FreeBSD, OpenBSD and -NetBSD. Ghostunnel also supports running on Windows, though without -signal-based certificate reload (use `--timed-reload` instead), syslog output, -Landlock sandboxing, and socket activation. +Ghostunnel is developed primarily for Linux and macOS but runs on any UNIX +system with `SO_REUSEPORT` (FreeBSD, OpenBSD, NetBSD). Windows is also +supported, though without signal-based certificate reload (use +`--timed-reload`), syslog, Landlock sandboxing, or socket activation. ## License From 48b10b5efea12feb61a80beb8ac3dd88549faa04 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Thu, 23 Apr 2026 21:32:15 -0700 Subject: [PATCH 09/23] Add integration tests for Windows SCM --- magefile.go | 68 ++++++++++++---- tests/common.py | 9 ++ tests/test-service-graceful-shutdown.py | 88 ++++++++++++++++++++ tests/test-service-install-bad-name.py | 23 ++++++ tests/test-service-install-no-args.py | 21 +++++ tests/test-service-lifecycle.py | 104 ++++++++++++++++++++++++ tests/test-service-status-not-found.py | 19 +++++ tests/test-service-uninstall-foreign.py | 59 ++++++++++++++ 8 files changed, 375 insertions(+), 16 deletions(-) create mode 100644 tests/test-service-graceful-shutdown.py create mode 100644 tests/test-service-install-bad-name.py create mode 100644 tests/test-service-install-no-args.py create mode 100644 tests/test-service-lifecycle.py create mode 100644 tests/test-service-status-not-found.py create mode 100644 tests/test-service-uninstall-foreign.py diff --git a/magefile.go b/magefile.go index 7af54fac06..a3709aedfb 100644 --- a/magefile.go +++ b/magefile.go @@ -401,6 +401,17 @@ func pythonCmd() string { return "python3" } +// cleanCoverage removes stale coverage profiles from previous runs and +// recreates the coverage directory. Used as a mage dep so that mage's +// built-in deduplication ensures it runs exactly once per invocation, +// even when Unit and Integration are triggered in parallel by Test.All. +func cleanCoverage() error { + if err := os.RemoveAll("coverage"); err != nil { + return fmt.Errorf("failed to remove coverage directory: %w", err) + } + return os.MkdirAll("coverage", 0755) +} + // build builds the *test* binary with coverage instrumentation. func (Test) build() error { return sh.Run("go", "test", "-c", "-covermode=count", "-coverpkg", ".,./auth,./certloader,./certloader/jceks,./proxy,./wildcard,./socket") @@ -415,23 +426,17 @@ func (Test) All(ctx context.Context) error { // Unit runs the unit tests. func (Test) Unit(ctx context.Context) error { + mg.Deps(cleanCoverage) printf("Running unit tests...\n") - if err := os.MkdirAll("coverage", 0755); err != nil { - return fmt.Errorf("failed to create coverage directory: %w", err) - } - - return sh.Run("go", "test", "-v", "-covermode=count", "-coverpkg", ".,./auth,./certloader,./certloader/jceks,./proxy,./wildcard,./socket", "-coverprofile=coverage/unit-test.profile", "./...") + return sh.Run("go", "test", "-v", "-covermode=count", "-coverprofile=coverage/unit-test.profile", "./...") } // Integration runs the integration tests in parallel. // Set GHOSTUNNEL_TEST_PARALLEL to control concurrency (default: NumCPU, max 16). func (Test) Integration(ctx context.Context) error { mg.CtxDeps(ctx, Test.build) - - if err := os.MkdirAll("coverage", 0755); err != nil { - return fmt.Errorf("failed to create coverage directory: %w", err) - } + mg.Deps(cleanCoverage) // Run integration tests testFiles, err := filepath.Glob("tests/test-*.py") @@ -549,10 +554,7 @@ func (Test) Integration(ctx context.Context) error { // mage test:single test-server-listen-port-conflict.py func (Test) Single(ctx context.Context, name string) error { mg.CtxDeps(ctx, Test.build) - - if err := os.MkdirAll("coverage", 0755); err != nil { - return fmt.Errorf("failed to create coverage directory: %w", err) - } + mg.Deps(cleanCoverage) // Normalize the test name name = strings.TrimSuffix(name, ".py") @@ -623,11 +625,45 @@ func (Test) Coverage(ctx context.Context) error { return fmt.Errorf("failed to merge coverage: %w", err) } - // Filter out internal/test lines (same as Makefile's grep -v) - lines := strings.Split(string(mergeOutput), "\n") + // Filter to only include coverage for the packages we care about. + // The integration test binary uses -coverpkg to instrument these packages; + // unit tests run without -coverpkg (to avoid overlapping coverage blocks), + // so this allowlist ensures only the desired packages appear in the final + // merged profile. + coveredPkgs := []string{ + "github.com/ghostunnel/ghostunnel/", + "github.com/ghostunnel/ghostunnel:", + } + excludedPkgs := []string{ + "/vendor/", + "/internal/test", + "/jcekstest", + } + lines := strings.Split(mergeOutput, "\n") var filtered []string for _, line := range lines { - if !strings.Contains(line, "internal/test") && !strings.Contains(line, "jcekstest") { + if strings.HasPrefix(line, "mode:") { + filtered = append(filtered, line) + continue + } + allowed := false + for _, pkg := range coveredPkgs { + if strings.Contains(line, pkg) { + allowed = true + break + } + } + if !allowed { + continue + } + excluded := false + for _, pkg := range excludedPkgs { + if strings.Contains(line, pkg) { + excluded = true + break + } + } + if !excluded { filtered = append(filtered, line) } } diff --git a/tests/common.py b/tests/common.py index c9b1e19e1a..8127093647 100755 --- a/tests/common.py +++ b/tests/common.py @@ -403,6 +403,15 @@ def require_platform(*platforms): '/'.join(platforms), current), file=sys.stderr) sys.exit(2) +def require_admin(): + """Skip the test (exit 2) unless running with Administrator privileges (Windows).""" + if not IS_WINDOWS: + return + import ctypes + if not ctypes.windll.shell32.IsUserAnAdmin(): + print("requires Administrator privileges, skipping", file=sys.stderr) + sys.exit(2) + def reload_args(): """Extra args to enable certificate reload on Windows via --timed-reload.""" return ['--timed-reload=1s'] if IS_WINDOWS else [] diff --git a/tests/test-service-graceful-shutdown.py b/tests/test-service-graceful-shutdown.py new file mode 100644 index 0000000000..72dccc8655 --- /dev/null +++ b/tests/test-service-graceful-shutdown.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +""" +Tests graceful shutdown with connection draining on Windows. + +Verifies that an in-flight connection can complete after shutdown is +triggered via the /_shutdown HTTP endpoint, and that ghostunnel exits +cleanly with code 0. + +Note: this exercises the same shutdownFunc() that the Windows SCM stop +handler invokes via serviceStopCh, but the trigger mechanism here is +/_shutdown (not the SCM). A full SCM-triggered test would require a real +ghostunnel.exe binary rather than the coverage-instrumented test binary. +""" + +import http.client +import time +import urllib.request + +from common import (LOCALHOST, LISTEN_PORT, STATUS_PORT, TARGET_PORT, + RootCert, SocketPair, TcpServer, TlsClient, print_ok, + require_platform, run_ghostunnel, terminate, urlopen) + +require_platform('Windows') + +ghostunnel = None +root = None +try: + root = RootCert('root') + root.create_signed_cert('server') + root.create_signed_cert('client') + + ghostunnel = run_ghostunnel(['server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + '--enable-shutdown', + '--shutdown-timeout=30s', + '--status={0}:{1}'.format(LOCALHOST, STATUS_PORT)]) + + # Wait for startup + TlsClient(None, 'root', STATUS_PORT).connect(20, 'server') + + # Establish a connection before shutdown + pair = SocketPair( + TlsClient('client', 'root', LISTEN_PORT), TcpServer(TARGET_PORT)) + pair.validate_can_send_from_client("hello", "send before shutdown") + + # Trigger graceful shutdown + print_ok("triggering shutdown via /_shutdown") + try: + urlopen(urllib.request.Request( + "https://{0}:{1}/_shutdown".format(LOCALHOST, STATUS_PORT), + method='POST')) + except http.client.RemoteDisconnected: + pass # expected: server may close before sending response + + # Verify in-flight connection can still complete + pair.validate_can_send_from_server("world", "send after shutdown triggered") + pair.validate_closing_client_closes_server("drain completes") + + # Wait for ghostunnel to exit + stopped = False + for _ in range(90): + try: + ghostunnel.wait(timeout=1) + except Exception: + pass + if ghostunnel.poll() is not None: + stopped = True + break + time.sleep(1) + + if not stopped: + raise Exception('ghostunnel did not terminate within 90 seconds') + + if ghostunnel.returncode != 0: + raise Exception( + 'ghostunnel terminated with non-zero exit code: {0}'.format( + ghostunnel.returncode)) + + print_ok("OK (terminated)") +finally: + terminate(ghostunnel) + if root: + root.cleanup() diff --git a/tests/test-service-install-bad-name.py b/tests/test-service-install-bad-name.py new file mode 100644 index 0000000000..b3708db8c9 --- /dev/null +++ b/tests/test-service-install-bad-name.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 + +""" +Tests that 'ghostunnel service install' with an invalid service name +(containing special characters) exits with a non-zero code. Does not +require Administrator privileges since name validation fails before +connecting to the SCM. +""" + +import subprocess + +from common import (assert_not_zero, print_ok, require_platform, + run_ghostunnel) + +require_platform('Windows') + +ghostunnel = run_ghostunnel( + ['service', 'install', '--service-name', 'bad/name<>', + '--', 'server', '--listen', ':8443', '--target', 'localhost:8080', + '--keystore=server.p12', '--cacert=root.crt', '--allow-ou=test'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) +assert_not_zero(ghostunnel) +print_ok("OK") diff --git a/tests/test-service-install-no-args.py b/tests/test-service-install-no-args.py new file mode 100644 index 0000000000..ccb4b15047 --- /dev/null +++ b/tests/test-service-install-no-args.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +""" +Tests that 'ghostunnel service install' without proxy arguments (no '--' +separator followed by server/client args) exits with a non-zero code and +a clear error message. Does not require Administrator privileges since +validation fails before connecting to the SCM. +""" + +import subprocess + +from common import (assert_not_zero, print_ok, require_platform, + run_ghostunnel) + +require_platform('Windows') + +ghostunnel = run_ghostunnel( + ['service', 'install', '--service-name', 'ghostunnel-pytest-noargs'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) +assert_not_zero(ghostunnel) +print_ok("OK") diff --git a/tests/test-service-lifecycle.py b/tests/test-service-lifecycle.py new file mode 100644 index 0000000000..6be588396d --- /dev/null +++ b/tests/test-service-lifecycle.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +""" +Tests the full Windows service lifecycle: install -> status -> stop -> +uninstall -> verify-gone. Requires Windows and Administrator privileges. + +The test binary cannot actually start as a Windows service (the SCM invokes +main(), which is the Go test runner, not ghostunnel's main). We tolerate the +start failure and verify the SCM registration itself. +""" + +import subprocess +import sys + +from common import (LOCALHOST, LISTEN_PORT, TARGET_PORT, assert_not_zero, + print_ok, require_admin, require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +SERVICE_NAME = 'ghostunnel-pytest-lifecycle' + + +def run_service_cmd(args): + """Run a ghostunnel service subcommand and return (returncode, stdout, stderr).""" + proc = run_ghostunnel(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=30) + return proc.returncode, stdout.decode(), stderr.decode() + + +try: + # Clean up any leftover service from a previous crashed test run. + # If the service doesn't exist, this fails silently. + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + + # 1. Install service. The test binary can't start as a real service, + # so we tolerate a non-zero exit from the start failure. + rc, stdout, stderr = run_service_cmd([ + 'service', 'install', '--service-name', SERVICE_NAME, + '--', 'server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore=server.p12', + '--cacert=root.crt', + '--allow-ou=client', + ]) + # The install itself should succeed (service registered in SCM), + # but starting may fail. Check for the "installed" message or + # the "could not be started" message (both indicate registration worked). + combined = stdout + stderr + if 'installed' not in combined.lower() and 'could not be started' not in combined.lower(): + print("unexpected install output:\nstdout: {0}\nstderr: {1}".format( + stdout, stderr), file=sys.stderr) + raise Exception("service install did not produce expected output") + print_ok("install: OK (registered in SCM)") + + # 2. Status should succeed (service is registered). + rc, stdout, stderr = run_service_cmd([ + 'service', 'status', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("status failed (rc={0}): {1}".format(rc, stdout)) + if SERVICE_NAME not in stdout: + raise Exception("status output missing service name: {0}".format(stdout)) + print_ok("status: OK ({0})".format(stdout.strip())) + + # 3. Stop (service is already stopped since it never started). + rc, stdout, stderr = run_service_cmd([ + 'service', 'stop', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("stop failed (rc={0}): {1}".format(rc, stdout)) + print_ok("stop: OK") + + # 4. Uninstall. + rc, stdout, stderr = run_service_cmd([ + 'service', 'uninstall', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("uninstall failed (rc={0}): {1}".format(rc, stdout)) + if 'removed' not in stdout.lower(): + raise Exception("uninstall output missing 'removed': {0}".format(stdout)) + print_ok("uninstall: OK") + + # 5. Status should fail now (service is gone). + proc = run_ghostunnel( + ['service', 'status', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert_not_zero(proc) + print_ok("status after uninstall: correctly reports error") + + print_ok("OK") +finally: + # Best-effort cleanup in case the test failed partway through. + try: + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.wait(timeout=10) + except Exception: + pass diff --git a/tests/test-service-status-not-found.py b/tests/test-service-status-not-found.py new file mode 100644 index 0000000000..dfc717ce9b --- /dev/null +++ b/tests/test-service-status-not-found.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +""" +Tests that 'ghostunnel service status' for a non-existent service exits +with a non-zero code. Requires Windows and Administrator privileges +(mgr.Connect() requests SC_MANAGER_ALL_ACCESS). +""" + +from common import (assert_not_zero, print_ok, require_admin, + require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +ghostunnel = run_ghostunnel([ + 'service', 'status', '--service-name', 'ghostunnel-nonexistent-99999', +]) +assert_not_zero(ghostunnel) +print_ok("OK") diff --git a/tests/test-service-uninstall-foreign.py b/tests/test-service-uninstall-foreign.py new file mode 100644 index 0000000000..f9e655e6d1 --- /dev/null +++ b/tests/test-service-uninstall-foreign.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +""" +Tests that ghostunnel refuses to uninstall a service it did not install. +Creates a dummy service via sc.exe, then verifies ghostunnel's 'service +uninstall' rejects it because the GhostunnelManaged registry marker is +missing. Requires Windows and Administrator privileges. +""" + +import subprocess +import sys + +from common import (assert_not_zero, print_ok, require_admin, + require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +SERVICE_NAME = 'ghostunnel-pytest-foreign' + +try: + # Clean up any leftover dummy service from a previous crashed test run. + subprocess.call(['sc.exe', 'delete', SERVICE_NAME], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Create a dummy service that ghostunnel did not install. + subprocess.check_call([ + 'sc.exe', 'create', SERVICE_NAME, + 'binPath=', 'C:\\Windows\\System32\\cmd.exe', + 'start=', 'demand', + ]) + print_ok("created dummy service {0}".format(SERVICE_NAME)) + + # Attempt to uninstall it via ghostunnel -- should be refused. + # Errors from run() go through t.Errorf in the Go test harness, + # which prints to stdout (not stderr) when the test fails. + ghostunnel = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, _ = ghostunnel.communicate(timeout=30) + stdout = stdout.decode() + + if ghostunnel.returncode == 0: + raise Exception("expected non-zero exit code for foreign service uninstall") + + if 'not' not in stdout.lower() or 'ghostunnel' not in stdout.lower(): + print("unexpected stdout: {0}".format(stdout), file=sys.stderr) + raise Exception( + "expected 'not managed by ghostunnel' error, got: {0}".format(stdout)) + + print_ok("correctly refused to uninstall foreign service") + print_ok("OK") +finally: + # Clean up the dummy service. + try: + subprocess.call(['sc.exe', 'delete', SERVICE_NAME], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + pass From b4267c3d4f4164a42bd5a0caa5357164e4dfdf48 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Thu, 23 Apr 2026 22:19:57 -0700 Subject: [PATCH 10/23] Improve integration test harness and coverage calculation --- .gitignore | 2 +- Dockerfile-test | 6 +- coverage_enabled.go | 27 ++ go.mod | 2 - go.sum | 2 - landlock_linux.go | 18 +- landlock_other.go | 2 - magefile.go | 168 +++++++---- main.go | 5 + main_test.go | 64 ----- tests/common.py | 43 ++- .../test-client-systemd-socket-activation.py | 3 +- tests/test-service-uninstall-foreign.py | 11 +- vendor/github.com/wadey/gocovmerge/LICENSE | 22 -- vendor/github.com/wadey/gocovmerge/README.md | 16 -- .../github.com/wadey/gocovmerge/gocovmerge.go | 111 -------- vendor/golang.org/x/tools/cover/profile.go | 266 ------------------ vendor/modules.txt | 4 - 18 files changed, 191 insertions(+), 581 deletions(-) create mode 100644 coverage_enabled.go delete mode 100644 vendor/github.com/wadey/gocovmerge/LICENSE delete mode 100644 vendor/github.com/wadey/gocovmerge/README.md delete mode 100644 vendor/github.com/wadey/gocovmerge/gocovmerge.go delete mode 100644 vendor/golang.org/x/tools/cover/profile.go diff --git a/.gitignore b/.gitignore index f635bbc349..a6f4f300a9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ ghostunnel ghostunnel.man mage-bin ghostunnel.exe -ghostunnel.test +ghostunnel.cover ghostunnel.certstore ghostunnel-* __pycache__ diff --git a/Dockerfile-test b/Dockerfile-test index de5658b291..70761590b9 100644 --- a/Dockerfile-test +++ b/Dockerfile-test @@ -14,12 +14,16 @@ FROM golang:${GO_VERSION} RUN apt-get update && \ apt-get install -y build-essential python3-minimal netcat-traditional softhsm2 rsyslog git openssl default-jre-headless = pb.StartLine && (pi.StartLine != pb.StartLine || pi.StartCol >= pb.StartCol) - } - - i := 0 - if sortFunc(i) != true { - i = sort.Search(len(p.Blocks)-startIndex, sortFunc) - } - i += startIndex - if i < len(p.Blocks) && p.Blocks[i].StartLine == pb.StartLine && p.Blocks[i].StartCol == pb.StartCol { - if p.Blocks[i].EndLine != pb.EndLine || p.Blocks[i].EndCol != pb.EndCol { - log.Fatalf("OVERLAP MERGE: %v %v %v", p.FileName, p.Blocks[i], pb) - } - switch p.Mode { - case "set": - p.Blocks[i].Count |= pb.Count - case "count", "atomic": - p.Blocks[i].Count += pb.Count - default: - log.Fatalf("unsupported covermode: '%s'", p.Mode) - } - } else { - if i > 0 { - pa := p.Blocks[i-1] - if pa.EndLine >= pb.EndLine && (pa.EndLine != pb.EndLine || pa.EndCol > pb.EndCol) { - log.Fatalf("OVERLAP BEFORE: %v %v %v", p.FileName, pa, pb) - } - } - if i < len(p.Blocks)-1 { - pa := p.Blocks[i+1] - if pa.StartLine <= pb.StartLine && (pa.StartLine != pb.StartLine || pa.StartCol < pb.StartCol) { - log.Fatalf("OVERLAP AFTER: %v %v %v", p.FileName, pa, pb) - } - } - p.Blocks = append(p.Blocks, cover.ProfileBlock{}) - copy(p.Blocks[i+1:], p.Blocks[i:]) - p.Blocks[i] = pb - } - return i + 1 -} - -func addProfile(profiles []*cover.Profile, p *cover.Profile) []*cover.Profile { - i := sort.Search(len(profiles), func(i int) bool { return profiles[i].FileName >= p.FileName }) - if i < len(profiles) && profiles[i].FileName == p.FileName { - mergeProfiles(profiles[i], p) - } else { - profiles = append(profiles, nil) - copy(profiles[i+1:], profiles[i:]) - profiles[i] = p - } - return profiles -} - -func dumpProfiles(profiles []*cover.Profile, out io.Writer) { - if len(profiles) == 0 { - return - } - fmt.Fprintf(out, "mode: %s\n", profiles[0].Mode) - for _, p := range profiles { - for _, b := range p.Blocks { - fmt.Fprintf(out, "%s:%d.%d,%d.%d %d %d\n", p.FileName, b.StartLine, b.StartCol, b.EndLine, b.EndCol, b.NumStmt, b.Count) - } - } -} - -func main() { - flag.Parse() - - var merged []*cover.Profile - - for _, file := range flag.Args() { - profiles, err := cover.ParseProfiles(file) - if err != nil { - log.Fatalf("failed to parse profiles: %v", err) - } - for _, p := range profiles { - merged = addProfile(merged, p) - } - } - - dumpProfiles(merged, os.Stdout) -} diff --git a/vendor/golang.org/x/tools/cover/profile.go b/vendor/golang.org/x/tools/cover/profile.go deleted file mode 100644 index 47a9a54116..0000000000 --- a/vendor/golang.org/x/tools/cover/profile.go +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright 2013 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package cover provides support for parsing coverage profiles -// generated by "go test -coverprofile=cover.out". -package cover // import "golang.org/x/tools/cover" - -import ( - "bufio" - "errors" - "fmt" - "io" - "math" - "os" - "sort" - "strconv" - "strings" -) - -// Profile represents the profiling data for a specific file. -type Profile struct { - FileName string - Mode string - Blocks []ProfileBlock -} - -// ProfileBlock represents a single block of profiling data. -type ProfileBlock struct { - StartLine, StartCol int - EndLine, EndCol int - NumStmt, Count int -} - -type byFileName []*Profile - -func (p byFileName) Len() int { return len(p) } -func (p byFileName) Less(i, j int) bool { return p[i].FileName < p[j].FileName } -func (p byFileName) Swap(i, j int) { p[i], p[j] = p[j], p[i] } - -// ParseProfiles parses profile data in the specified file and returns a -// Profile for each source file described therein. -func ParseProfiles(fileName string) ([]*Profile, error) { - pf, err := os.Open(fileName) - if err != nil { - return nil, err - } - defer pf.Close() - return ParseProfilesFromReader(pf) -} - -// ParseProfilesFromReader parses profile data from the Reader and -// returns a Profile for each source file described therein. -func ParseProfilesFromReader(rd io.Reader) ([]*Profile, error) { - // First line is "mode: foo", where foo is "set", "count", or "atomic". - // Rest of file is in the format - // encoding/base64/base64.go:34.44,37.40 3 1 - // where the fields are: name.go:line.column,line.column numberOfStatements count - files := make(map[string]*Profile) - s := bufio.NewScanner(rd) - mode := "" - for s.Scan() { - line := s.Text() - if mode == "" { - const p = "mode: " - if !strings.HasPrefix(line, p) || line == p { - return nil, fmt.Errorf("bad mode line: %v", line) - } - mode = line[len(p):] - continue - } - fn, b, err := parseLine(line) - if err != nil { - return nil, fmt.Errorf("line %q doesn't match expected format: %v", line, err) - } - p := files[fn] - if p == nil { - p = &Profile{ - FileName: fn, - Mode: mode, - } - files[fn] = p - } - p.Blocks = append(p.Blocks, b) - } - if err := s.Err(); err != nil { - return nil, err - } - for _, p := range files { - sort.Sort(blocksByStart(p.Blocks)) - // Merge samples from the same location. - j := 1 - for i := 1; i < len(p.Blocks); i++ { - b := p.Blocks[i] - last := p.Blocks[j-1] - if b.StartLine == last.StartLine && - b.StartCol == last.StartCol && - b.EndLine == last.EndLine && - b.EndCol == last.EndCol { - if b.NumStmt != last.NumStmt { - return nil, fmt.Errorf("inconsistent NumStmt: changed from %d to %d", last.NumStmt, b.NumStmt) - } - if mode == "set" { - p.Blocks[j-1].Count |= b.Count - } else { - p.Blocks[j-1].Count += b.Count - } - continue - } - p.Blocks[j] = b - j++ - } - p.Blocks = p.Blocks[:j] - } - // Generate a sorted slice. - profiles := make([]*Profile, 0, len(files)) - for _, profile := range files { - profiles = append(profiles, profile) - } - sort.Sort(byFileName(profiles)) - return profiles, nil -} - -// parseLine parses a line from a coverage file. -// It is equivalent to the regex -// ^(.+):([0-9]+)\.([0-9]+),([0-9]+)\.([0-9]+) ([0-9]+) ([0-9]+)$ -// -// However, it is much faster: https://golang.org/cl/179377 -func parseLine(l string) (fileName string, block ProfileBlock, err error) { - end := len(l) - - b := ProfileBlock{} - b.Count, end, err = seekBack(l, ' ', end, "Count") - if err != nil { - return "", b, err - } - b.NumStmt, end, err = seekBack(l, ' ', end, "NumStmt") - if err != nil { - return "", b, err - } - b.EndCol, end, err = seekBack(l, '.', end, "EndCol") - if err != nil { - return "", b, err - } - b.EndLine, end, err = seekBack(l, ',', end, "EndLine") - if err != nil { - return "", b, err - } - b.StartCol, end, err = seekBack(l, '.', end, "StartCol") - if err != nil { - return "", b, err - } - b.StartLine, end, err = seekBack(l, ':', end, "StartLine") - if err != nil { - return "", b, err - } - fn := l[0:end] - if fn == "" { - return "", b, errors.New("a FileName cannot be blank") - } - return fn, b, nil -} - -// seekBack searches backwards from end to find sep in l, then returns the -// value between sep and end as an integer. -// If seekBack fails, the returned error will reference what. -func seekBack(l string, sep byte, end int, what string) (value int, nextSep int, err error) { - // Since we're seeking backwards and we know only ASCII is legal for these values, - // we can ignore the possibility of non-ASCII characters. - for start := end - 1; start >= 0; start-- { - if l[start] == sep { - i, err := strconv.Atoi(l[start+1 : end]) - if err != nil { - return 0, 0, fmt.Errorf("couldn't parse %q: %v", what, err) - } - if i < 0 { - return 0, 0, fmt.Errorf("negative values are not allowed for %s, found %d", what, i) - } - return i, start, nil - } - } - return 0, 0, fmt.Errorf("couldn't find a %s before %s", string(sep), what) -} - -type blocksByStart []ProfileBlock - -func (b blocksByStart) Len() int { return len(b) } -func (b blocksByStart) Swap(i, j int) { b[i], b[j] = b[j], b[i] } -func (b blocksByStart) Less(i, j int) bool { - bi, bj := b[i], b[j] - return bi.StartLine < bj.StartLine || bi.StartLine == bj.StartLine && bi.StartCol < bj.StartCol -} - -// Boundary represents the position in a source file of the beginning or end of a -// block as reported by the coverage profile. In HTML mode, it will correspond to -// the opening or closing of a tag and will be used to colorize the source -type Boundary struct { - Offset int // Location as a byte offset in the source file. - Start bool // Is this the start of a block? - Count int // Event count from the cover profile. - Norm float64 // Count normalized to [0..1]. - Index int // Order in input file. -} - -// Boundaries returns a Profile as a set of Boundary objects within the provided src. -func (p *Profile) Boundaries(src []byte) (boundaries []Boundary) { - // Find maximum count. - max := 0 - for _, b := range p.Blocks { - if b.Count > max { - max = b.Count - } - } - // Divisor for normalization. - divisor := math.Log(float64(max)) - - // boundary returns a Boundary, populating the Norm field with a normalized Count. - index := 0 - boundary := func(offset int, start bool, count int) Boundary { - b := Boundary{Offset: offset, Start: start, Count: count, Index: index} - index++ - if !start || count == 0 { - return b - } - if max <= 1 { - b.Norm = 0.8 // Profile is in"set" mode; we want a heat map. Use cov8 in the CSS. - } else if count > 0 { - b.Norm = math.Log(float64(count)) / divisor - } - return b - } - - line, col := 1, 2 // TODO: Why is this 2? - for si, bi := 0, 0; si < len(src) && bi < len(p.Blocks); { - b := p.Blocks[bi] - if b.StartLine == line && b.StartCol == col { - boundaries = append(boundaries, boundary(si, true, b.Count)) - } - if b.EndLine == line && b.EndCol == col || line > b.EndLine { - boundaries = append(boundaries, boundary(si, false, 0)) - bi++ - continue // Don't advance through src; maybe the next block starts here. - } - if src[si] == '\n' { - line++ - col = 0 - } - col++ - si++ - } - sort.Sort(boundariesByPos(boundaries)) - return -} - -type boundariesByPos []Boundary - -func (b boundariesByPos) Len() int { return len(b) } -func (b boundariesByPos) Swap(i, j int) { b[i], b[j] = b[j], b[i] } -func (b boundariesByPos) Less(i, j int) bool { - if b[i].Offset == b[j].Offset { - // Boundaries at the same offset should be ordered according to - // their original position. - return b[i].Index < b[j].Index - } - return b[i].Offset < b[j].Offset -} diff --git a/vendor/modules.txt b/vendor/modules.txt index c427fede93..db3d759115 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1211,9 +1211,6 @@ github.com/vektah/gqlparser/v2/parser github.com/vektah/gqlparser/v2/validator github.com/vektah/gqlparser/v2/validator/core github.com/vektah/gqlparser/v2/validator/rules -# github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad -## explicit -github.com/wadey/gocovmerge # github.com/wrouesnel/go.connect-proxy-scheme v0.0.0-20240822095422-f6d0c8f327b9 ## explicit; go 1.23 github.com/wrouesnel/go.connect-proxy-scheme @@ -1376,7 +1373,6 @@ golang.org/x/text/unicode/norm golang.org/x/text/width # golang.org/x/tools v0.43.0 ## explicit; go 1.25.0 -golang.org/x/tools/cover golang.org/x/tools/go/analysis golang.org/x/tools/go/analysis/passes/appends golang.org/x/tools/go/analysis/passes/asmdecl From 20dc5f4c4e25cd8238f6ad13104d313ef6034dbc Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Fri, 24 Apr 2026 20:21:35 -0700 Subject: [PATCH 11/23] Update tests to reflect changes in coverage instrumentation --- tests/test-service-graceful-shutdown.py | 3 +-- tests/test-service-lifecycle.py | 23 +++++++++++------------ tests/test-service-uninstall-foreign.py | 2 -- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/test-service-graceful-shutdown.py b/tests/test-service-graceful-shutdown.py index 72dccc8655..841623957e 100644 --- a/tests/test-service-graceful-shutdown.py +++ b/tests/test-service-graceful-shutdown.py @@ -9,8 +9,7 @@ Note: this exercises the same shutdownFunc() that the Windows SCM stop handler invokes via serviceStopCh, but the trigger mechanism here is -/_shutdown (not the SCM). A full SCM-triggered test would require a real -ghostunnel.exe binary rather than the coverage-instrumented test binary. +/_shutdown (not the SCM). """ import http.client diff --git a/tests/test-service-lifecycle.py b/tests/test-service-lifecycle.py index 6be588396d..ecbfdf9fe5 100644 --- a/tests/test-service-lifecycle.py +++ b/tests/test-service-lifecycle.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 """ -Tests the full Windows service lifecycle: install -> status -> stop -> +Tests the full Windows service lifecycle: install -> start -> status -> stop -> uninstall -> verify-gone. Requires Windows and Administrator privileges. -The test binary cannot actually start as a Windows service (the SCM invokes -main(), which is the Go test runner, not ghostunnel's main). We tolerate the -start failure and verify the SCM registration itself. +The coverage-instrumented binary (built with go build -cover) is a real +ghostunnel binary, so the SCM can start it as a Windows service. """ import subprocess @@ -36,8 +35,7 @@ def run_service_cmd(args): stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.communicate(timeout=10) - # 1. Install service. The test binary can't start as a real service, - # so we tolerate a non-zero exit from the start failure. + # 1. Install and start service. rc, stdout, stderr = run_service_cmd([ 'service', 'install', '--service-name', SERVICE_NAME, '--', 'server', @@ -47,15 +45,16 @@ def run_service_cmd(args): '--cacert=root.crt', '--allow-ou=client', ]) - # The install itself should succeed (service registered in SCM), - # but starting may fail. Check for the "installed" message or - # the "could not be started" message (both indicate registration worked). combined = stdout + stderr - if 'installed' not in combined.lower() and 'could not be started' not in combined.lower(): + if rc != 0: + print("unexpected install output:\nstdout: {0}\nstderr: {1}".format( + stdout, stderr), file=sys.stderr) + raise Exception("service install failed (rc={0})".format(rc)) + if 'installed and started' not in combined.lower(): print("unexpected install output:\nstdout: {0}\nstderr: {1}".format( stdout, stderr), file=sys.stderr) raise Exception("service install did not produce expected output") - print_ok("install: OK (registered in SCM)") + print_ok("install: OK (installed and started)") # 2. Status should succeed (service is registered). rc, stdout, stderr = run_service_cmd([ @@ -67,7 +66,7 @@ def run_service_cmd(args): raise Exception("status output missing service name: {0}".format(stdout)) print_ok("status: OK ({0})".format(stdout.strip())) - # 3. Stop (service is already stopped since it never started). + # 3. Stop the running service. rc, stdout, stderr = run_service_cmd([ 'service', 'stop', '--service-name', SERVICE_NAME, ]) diff --git a/tests/test-service-uninstall-foreign.py b/tests/test-service-uninstall-foreign.py index b9b38b879f..4efff041e8 100644 --- a/tests/test-service-uninstall-foreign.py +++ b/tests/test-service-uninstall-foreign.py @@ -32,8 +32,6 @@ print_ok("created dummy service {0}".format(SERVICE_NAME)) # Attempt to uninstall it via ghostunnel -- should be refused. - # Errors from run() go through t.Errorf in the Go test harness, - # which prints to stdout (not stderr) when the test fails. ghostunnel = run_ghostunnel( ['service', 'uninstall', '--service-name', SERVICE_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE) From 025e438c06f3e5644a87ebf4b7efc1a908bd016c Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Fri, 24 Apr 2026 20:27:41 -0700 Subject: [PATCH 12/23] Add a few more tests for Windows SCM --- tests/test-service-foreign-start-stop.py | 70 ++++++++++++ tests/test-service-install-duplicate.py | 87 +++++++++++++++ tests/test-service-lifecycle.py | 19 +++- tests/test-service-not-found.py | 40 +++++++ tests/test-service-start-stop.py | 133 +++++++++++++++++++++++ 5 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 tests/test-service-foreign-start-stop.py create mode 100644 tests/test-service-install-duplicate.py create mode 100644 tests/test-service-not-found.py create mode 100644 tests/test-service-start-stop.py diff --git a/tests/test-service-foreign-start-stop.py b/tests/test-service-foreign-start-stop.py new file mode 100644 index 0000000000..8867f7f19b --- /dev/null +++ b/tests/test-service-foreign-start-stop.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 + +""" +Tests that ghostunnel refuses to start or stop a service it did not install. +Creates a dummy service via sc.exe, then verifies that 'service start' and +'service stop' both reject it because the GhostunnelManaged registry marker +is missing. Requires Windows and Administrator privileges. +""" + +import subprocess +import sys + +from common import (print_ok, require_admin, require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +SERVICE_NAME = 'ghostunnel-pytest-foreign-ss' + +try: + # Clean up any leftover dummy service from a previous crashed test run. + subprocess.call(['sc.exe', 'delete', SERVICE_NAME], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Create a dummy service that ghostunnel did not install. + subprocess.check_call([ + 'sc.exe', 'create', SERVICE_NAME, + 'binPath=', 'C:\\Windows\\System32\\cmd.exe', + 'start=', 'demand', + ]) + print_ok("created dummy service {0}".format(SERVICE_NAME)) + + # Attempt to start it via ghostunnel -- should be refused. + proc = run_ghostunnel( + ['service', 'start', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=30) + combined = (stdout + stderr).decode() + + if proc.returncode == 0: + raise Exception("expected non-zero exit code for foreign service start") + if 'not' not in combined.lower() or 'ghostunnel' not in combined.lower(): + print("unexpected output: {0!r}".format(combined), file=sys.stderr) + raise Exception( + "expected 'not managed by ghostunnel' error, got: {0}".format(combined)) + print_ok("start correctly refused for foreign service") + + # Attempt to stop it via ghostunnel -- should be refused. + proc = run_ghostunnel( + ['service', 'stop', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=30) + combined = (stdout + stderr).decode() + + if proc.returncode == 0: + raise Exception("expected non-zero exit code for foreign service stop") + if 'not' not in combined.lower() or 'ghostunnel' not in combined.lower(): + print("unexpected output: {0!r}".format(combined), file=sys.stderr) + raise Exception( + "expected 'not managed by ghostunnel' error, got: {0}".format(combined)) + print_ok("stop correctly refused for foreign service") + + print_ok("OK") +finally: + # Clean up the dummy service. + try: + subprocess.call(['sc.exe', 'delete', SERVICE_NAME], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + pass diff --git a/tests/test-service-install-duplicate.py b/tests/test-service-install-duplicate.py new file mode 100644 index 0000000000..3b64a1d9a6 --- /dev/null +++ b/tests/test-service-install-duplicate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +""" +Tests that installing a service with a name that already exists fails +with a non-zero exit code. Requires Windows and Administrator privileges. +""" + +import os +import subprocess +import sys + +from common import (LOCALHOST, LISTEN_PORT, TARGET_PORT, assert_not_zero, + create_default_certs, print_ok, require_admin, + require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +SERVICE_NAME = 'ghostunnel-pytest-duplicate' + +root = None + + +def run_service_cmd(args): + """Run a ghostunnel service subcommand and return (returncode, stdout, stderr).""" + proc = run_ghostunnel(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=30) + return proc.returncode, stdout.decode(), stderr.decode() + + +try: + root = create_default_certs() + + # Use absolute paths so the SCM-started service process can find certs + # regardless of its working directory. + keystore = os.path.abspath('server.p12') + cacert = os.path.abspath('root.crt') + + INSTALL_ARGS = [ + 'service', 'install', '--service-name', SERVICE_NAME, + '--', 'server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore={0}'.format(keystore), + '--cacert={0}'.format(cacert), + '--allow-ou=client', + ] + + # Clean up any leftover service from a previous crashed test run. + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + + # 1. First install should succeed. + rc, stdout, stderr = run_service_cmd(INSTALL_ARGS) + if rc != 0: + print("install output:\nstdout: {0}\nstderr: {1}".format( + stdout, stderr), file=sys.stderr) + raise Exception("first install failed (rc={0})".format(rc)) + print_ok("first install: OK") + + # 2. Second install with same name should fail. + proc = run_ghostunnel(INSTALL_ARGS, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert_not_zero(proc) + print_ok("duplicate install: correctly rejected") + + print_ok("OK") +finally: + # Best-effort cleanup. + try: + proc = run_ghostunnel( + ['service', 'stop', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + except Exception: + pass + try: + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + except Exception: + pass + if root: + root.cleanup() diff --git a/tests/test-service-lifecycle.py b/tests/test-service-lifecycle.py index ecbfdf9fe5..0b56a3ac09 100644 --- a/tests/test-service-lifecycle.py +++ b/tests/test-service-lifecycle.py @@ -8,17 +8,21 @@ ghostunnel binary, so the SCM can start it as a Windows service. """ +import os import subprocess import sys from common import (LOCALHOST, LISTEN_PORT, TARGET_PORT, assert_not_zero, - print_ok, require_admin, require_platform, run_ghostunnel) + create_default_certs, print_ok, require_admin, + require_platform, run_ghostunnel) require_platform('Windows') require_admin() SERVICE_NAME = 'ghostunnel-pytest-lifecycle' +root = None + def run_service_cmd(args): """Run a ghostunnel service subcommand and return (returncode, stdout, stderr).""" @@ -28,6 +32,8 @@ def run_service_cmd(args): try: + root = create_default_certs() + # Clean up any leftover service from a previous crashed test run. # If the service doesn't exist, this fails silently. proc = run_ghostunnel( @@ -35,14 +41,19 @@ def run_service_cmd(args): stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.communicate(timeout=10) + # Use absolute paths so the SCM-started service process can find certs + # regardless of its working directory. + keystore = os.path.abspath('server.p12') + cacert = os.path.abspath('root.crt') + # 1. Install and start service. rc, stdout, stderr = run_service_cmd([ 'service', 'install', '--service-name', SERVICE_NAME, '--', 'server', '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), - '--keystore=server.p12', - '--cacert=root.crt', + '--keystore={0}'.format(keystore), + '--cacert={0}'.format(cacert), '--allow-ou=client', ]) combined = stdout + stderr @@ -101,3 +112,5 @@ def run_service_cmd(args): proc.wait(timeout=10) except Exception: pass + if root: + root.cleanup() diff --git a/tests/test-service-not-found.py b/tests/test-service-not-found.py new file mode 100644 index 0000000000..06bf53ae85 --- /dev/null +++ b/tests/test-service-not-found.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +""" +Tests that 'service start', 'service stop', and 'service uninstall' all +exit with a non-zero code when the named service does not exist. Requires +Windows and Administrator privileges (SCM access). + +Complements test-service-status-not-found.py which covers 'service status'. +""" + +from common import (assert_not_zero, print_ok, require_admin, + require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +NONEXISTENT = 'ghostunnel-nonexistent-99999' + +# start +proc = run_ghostunnel([ + 'service', 'start', '--service-name', NONEXISTENT, +]) +assert_not_zero(proc) +print_ok("start non-existent: correctly rejected") + +# stop +proc = run_ghostunnel([ + 'service', 'stop', '--service-name', NONEXISTENT, +]) +assert_not_zero(proc) +print_ok("stop non-existent: correctly rejected") + +# uninstall +proc = run_ghostunnel([ + 'service', 'uninstall', '--service-name', NONEXISTENT, +]) +assert_not_zero(proc) +print_ok("uninstall non-existent: correctly rejected") + +print_ok("OK") diff --git a/tests/test-service-start-stop.py b/tests/test-service-start-stop.py new file mode 100644 index 0000000000..3ff86d613f --- /dev/null +++ b/tests/test-service-start-stop.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +""" +Tests standalone 'service start' and 'service stop' commands, including +edge cases: stopping an already-stopped service (idempotent) and starting +an already-running service (error). + +Requires Windows and Administrator privileges. +""" + +import os +import subprocess +import sys + +from common import (LOCALHOST, LISTEN_PORT, TARGET_PORT, assert_not_zero, + create_default_certs, print_ok, require_admin, + require_platform, run_ghostunnel) + +require_platform('Windows') +require_admin() + +SERVICE_NAME = 'ghostunnel-pytest-startstop' + +root = None + + +def run_service_cmd(args): + """Run a ghostunnel service subcommand and return (returncode, stdout, stderr).""" + proc = run_ghostunnel(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = proc.communicate(timeout=30) + return proc.returncode, stdout.decode(), stderr.decode() + + +try: + root = create_default_certs() + + # Clean up any leftover service from a previous crashed test run. + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + + # Use absolute paths so the SCM-started service process can find certs + # regardless of its working directory. + keystore = os.path.abspath('server.p12') + cacert = os.path.abspath('root.crt') + + # 1. Install service (auto-starts). + rc, stdout, stderr = run_service_cmd([ + 'service', 'install', '--service-name', SERVICE_NAME, + '--', 'server', + '--listen={0}:{1}'.format(LOCALHOST, LISTEN_PORT), + '--target={0}:{1}'.format(LOCALHOST, TARGET_PORT), + '--keystore={0}'.format(keystore), + '--cacert={0}'.format(cacert), + '--allow-ou=client', + ]) + if rc != 0: + print("install output:\nstdout: {0}\nstderr: {1}".format( + stdout, stderr), file=sys.stderr) + raise Exception("service install failed (rc={0})".format(rc)) + print_ok("install: OK") + + # 2. Stop via 'service stop'. + rc, stdout, stderr = run_service_cmd([ + 'service', 'stop', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("stop failed (rc={0}): {1}".format(rc, stderr)) + print_ok("stop: OK") + + # 3. Verify status reports stopped. + rc, stdout, stderr = run_service_cmd([ + 'service', 'status', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("status failed (rc={0}): {1}".format(rc, stderr)) + if 'stopped' not in stdout.lower(): + raise Exception("expected 'stopped' in status output: {0}".format(stdout)) + print_ok("status after stop: OK (stopped)") + + # 4. Stop again -- should succeed (already stopped). + rc, stdout, stderr = run_service_cmd([ + 'service', 'stop', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("stop-when-stopped failed (rc={0}): {1}".format(rc, stderr)) + print_ok("stop when already stopped: OK (idempotent)") + + # 5. Start via 'service start'. + rc, stdout, stderr = run_service_cmd([ + 'service', 'start', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("start failed (rc={0}): {1}".format(rc, stderr)) + print_ok("start: OK") + + # 6. Verify status reports running. + rc, stdout, stderr = run_service_cmd([ + 'service', 'status', '--service-name', SERVICE_NAME, + ]) + if rc != 0: + raise Exception("status failed (rc={0}): {1}".format(rc, stderr)) + if 'running' not in stdout.lower(): + raise Exception("expected 'running' in status output: {0}".format(stdout)) + print_ok("status after start: OK (running)") + + # 7. Start again -- should fail (already running). + proc = run_ghostunnel( + ['service', 'start', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert_not_zero(proc) + print_ok("start when already running: correctly rejected") + + print_ok("OK") +finally: + # Best-effort cleanup. + try: + proc = run_ghostunnel( + ['service', 'stop', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + except Exception: + pass + try: + proc = run_ghostunnel( + ['service', 'uninstall', '--service-name', SERVICE_NAME], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.communicate(timeout=10) + except Exception: + pass + if root: + root.cleanup() From 309313775be18df3dfd86ccff320ff2c3ee90aa2 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Sat, 25 Apr 2026 12:50:33 -0700 Subject: [PATCH 13/23] Add note about Windows SCM in README, availability in docs --- README.md | 2 +- docs/deployment/windows-service.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 20a87f5fef..e3aa0f5bfd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Linux, Landlock sandboxing is enabled by default to limit process privileges. and Prometheus metrics endpoints, plus optional pprof profiling. Ghostunnel also supports UNIX domain sockets, PROXY protocol v2, -systemd/launchd socket activation, and more. +systemd/launchd socket activation, Windows service management (SCM), and more. Getting Started =============== diff --git a/docs/deployment/windows-service.md b/docs/deployment/windows-service.md index df0bf9c0f3..52c3d90b01 100644 --- a/docs/deployment/windows-service.md +++ b/docs/deployment/windows-service.md @@ -4,6 +4,8 @@ description: Install and manage Ghostunnel as a native Windows service via the S weight: 25 --- +*Available since v1.11.0.* + Ghostunnel can run as a native Windows service managed by the [Service Control Manager][scm] (SCM). The `ghostunnel service` subcommands handle installation, removal, and lifecycle control. All service management From 066edde28381b5a720f14d410ce181f9c68b2bc9 Mon Sep 17 00:00:00 2001 From: Ben Dudley Date: Mon, 27 Apr 2026 15:03:42 -0500 Subject: [PATCH 14/23] Detect and report service start failure in service install/start Poll SCM after Start() and return an error if the service stops immediately (e.g. bad arguments), rolling back the service registration on install. Also suppress the spurious event-source-already-exists warning on reinstall, and write an Event Log error entry before rollback. Co-Authored-By: Claude Sonnet 4.6 --- windows_service.go | 56 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/windows_service.go b/windows_service.go index eca54e329f..8c220064e6 100644 --- a/windows_service.go +++ b/windows_service.go @@ -187,6 +187,39 @@ func checkGhostunnelMarker(name string) error { return nil } +// waitForServiceRunning polls the service status after a start command, waiting +// up to 10 seconds for it to reach the Running state. Returns an error if the +// service stops or fails to reach Running within the timeout. +func waitForServiceRunning(s *mgr.Service, name string) error { + deadline := time.Now().Add(10 * time.Second) + for { + status, err := s.Query() + if err != nil { + return fmt.Errorf("could not query service %q status: %w", name, err) + } + switch status.State { + case svc.Running: + // Brief stabilization: the service may crash immediately after + // reporting Running (e.g. flag parse failure calls os.Exit). + time.Sleep(300 * time.Millisecond) + status, err = s.Query() + if err != nil { + return fmt.Errorf("could not query service %q status: %w", name, err) + } + if status.State == svc.Stopped { + return fmt.Errorf("service %q stopped immediately after start; check service arguments", name) + } + return nil + case svc.Stopped: + return fmt.Errorf("service %q stopped immediately after start; check service arguments", name) + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for service %q to reach running state", name) + } + time.Sleep(300 * time.Millisecond) + } +} + // stopServiceWithTimeout sends a stop control to the service and waits up to // 30 seconds for it to reach the Stopped state. func stopServiceWithTimeout(s *mgr.Service, name string) error { @@ -244,9 +277,13 @@ func doInstallService(name string, proxyArgs []string) error { // Register an event source so Windows Event Log doesn't show // "The description for Event ID X from source ghostunnel cannot be found." - if err := eventlog.InstallAsEventCreate(name, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil { - // Non-fatal: the service will still work, log entries just look ugly. - fmt.Fprintf(os.Stderr, "warning: could not register event log source: %v\n", err) + // On reinstall the source already exists; skip registration to avoid a spurious warning. + if elog, err := eventlog.Open(name); err != nil { + if err := eventlog.InstallAsEventCreate(name, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not register event log source: %v\n", err) + } + } else { + elog.Close() } // Write a registry marker so uninstall can confirm this service was @@ -269,6 +306,15 @@ func doInstallService(name string, proxyArgs []string) error { return fmt.Errorf("service %q: %w: %w", name, errServiceNotStarted, err) } + if err := waitForServiceRunning(s, name); err != nil { + if elog, elogErr := eventlog.Open(name); elogErr == nil { + _ = elog.Error(1, fmt.Sprintf("ghostunnel service install failed: %v", err)) + elog.Close() + } + _ = s.Delete() + return fmt.Errorf("%w; service registration has been removed, fix the issue and re-run 'service install'", err) + } + fmt.Printf("Service %q installed and started successfully.\n", name) return nil } @@ -337,6 +383,10 @@ func doStartService(name string) error { return fmt.Errorf("could not start service %q: %w", name, err) } + if err := waitForServiceRunning(s, name); err != nil { + return err + } + fmt.Printf("Service %q started successfully.\n", name) return nil } From 58c5344edce9966d5b46874b72145e4b92fdcc2c Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 13:20:46 -0700 Subject: [PATCH 15/23] Improvements for readiness polling for Windows SCM --- windows_service.go | 125 ++++++++++++++++++++++++++-------- windows_service_test.go | 144 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 36 deletions(-) diff --git a/windows_service.go b/windows_service.go index 8c220064e6..9a4e36a602 100644 --- a/windows_service.go +++ b/windows_service.go @@ -38,6 +38,22 @@ var errServiceNotStarted = errors.New("service installed but could not be starte const ( serviceNameFlagName = "service-name" defaultServiceName = "ghostunnel" + + // serviceStateChangeTimeout bounds how long we wait for a service to + // transition between states (start or stop) before giving up. + serviceStateChangeTimeout = 30 * time.Second + // serviceStatePollInterval is the delay between SCM status queries while + // waiting for a state transition. + serviceStatePollInterval = 300 * time.Millisecond + // runningStabilizationDelay is the brief pause after observing Running to + // catch services that crash immediately after reporting Running (e.g. a + // flag parse failure that calls os.Exit). + runningStabilizationDelay = 300 * time.Millisecond + + // failedToStartMsg is the format string used whenever a service does not + // reach Running, regardless of which terminal state was observed. The + // Event Log is the source of truth for the underlying cause. + failedToStartMsg = "service %q failed to reach running state; check the Windows Event Log for details" ) var ( @@ -187,13 +203,60 @@ func checkGhostunnelMarker(name string) error { return nil } -// waitForServiceRunning polls the service status after a start command, waiting -// up to 10 seconds for it to reach the Running state. Returns an error if the -// service stops or fails to reach Running within the timeout. +// eventLogSourceExists reports whether an event log source with the given +// name is registered. Used to make event source registration idempotent. +func eventLogSourceExists(name string) bool { + elog, err := eventlog.Open(name) + if err != nil { + return false + } + elog.Close() + return true +} + +// writeEventLogInfo writes a one-shot Info entry to the named event log +// source. Best-effort; silently ignores errors (e.g. source not registered). +func writeEventLogInfo(name, msg string) { + elog, err := eventlog.Open(name) + if err != nil { + return + } + defer elog.Close() + _ = elog.Info(1, msg) +} + +// writeEventLogError writes a one-shot Error entry to the named event log +// source. Best-effort; silently ignores errors. +func writeEventLogError(name, msg string) { + elog, err := eventlog.Open(name) + if err != nil { + return + } + defer elog.Close() + _ = elog.Error(1, msg) +} + +// waitForServiceRunning polls the SCM after a start command and waits for the +// service to reach the Running state, returning an error if the service +// transitions to a terminal state or does not reach Running within +// serviceStateChangeTimeout. func waitForServiceRunning(s *mgr.Service, name string) error { - deadline := time.Now().Add(10 * time.Second) + return waitForServiceRunningPoll(name, s.Query, serviceStatePollInterval, runningStabilizationDelay, serviceStateChangeTimeout) +} + +// waitForServiceRunningPoll is the testable core of waitForServiceRunning. The +// poll interval, stabilization delay, and timeout are passed in so tests can +// use zero or near-zero durations to keep test runtime short. +func waitForServiceRunningPoll( + name string, + query func() (svc.Status, error), + pollInterval time.Duration, + stabilizationDelay time.Duration, + timeout time.Duration, +) error { + deadline := time.Now().Add(timeout) for { - status, err := s.Query() + status, err := query() if err != nil { return fmt.Errorf("could not query service %q status: %w", name, err) } @@ -201,27 +264,31 @@ func waitForServiceRunning(s *mgr.Service, name string) error { case svc.Running: // Brief stabilization: the service may crash immediately after // reporting Running (e.g. flag parse failure calls os.Exit). - time.Sleep(300 * time.Millisecond) - status, err = s.Query() + time.Sleep(stabilizationDelay) + status, err = query() if err != nil { return fmt.Errorf("could not query service %q status: %w", name, err) } - if status.State == svc.Stopped { - return fmt.Errorf("service %q stopped immediately after start; check service arguments", name) + if status.State != svc.Running { + return fmt.Errorf(failedToStartMsg, name) } return nil - case svc.Stopped: - return fmt.Errorf("service %q stopped immediately after start; check service arguments", name) + case svc.StartPending, svc.ContinuePending: + // Still transitioning; keep polling. + default: + // Stopped, StopPending, Paused, PausePending, or any unknown state + // observed before Running is a terminal failure. + return fmt.Errorf(failedToStartMsg, name) } if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for service %q to reach running state", name) } - time.Sleep(300 * time.Millisecond) + time.Sleep(pollInterval) } } // stopServiceWithTimeout sends a stop control to the service and waits up to -// 30 seconds for it to reach the Stopped state. +// serviceStateChangeTimeout for it to reach the Stopped state. func stopServiceWithTimeout(s *mgr.Service, name string) error { status, err := s.Query() if err != nil { @@ -233,12 +300,12 @@ func stopServiceWithTimeout(s *mgr.Service, name string) error { if _, err := s.Control(svc.Stop); err != nil { return fmt.Errorf("could not stop service %q: %w", name, err) } - deadline := time.Now().Add(30 * time.Second) + deadline := time.Now().Add(serviceStateChangeTimeout) for status.State != svc.Stopped { if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for service %q to stop", name) } - time.Sleep(300 * time.Millisecond) + time.Sleep(serviceStatePollInterval) if status, err = s.Query(); err != nil { return fmt.Errorf("could not query service %q: %w", name, err) } @@ -277,13 +344,15 @@ func doInstallService(name string, proxyArgs []string) error { // Register an event source so Windows Event Log doesn't show // "The description for Event ID X from source ghostunnel cannot be found." - // On reinstall the source already exists; skip registration to avoid a spurious warning. - if elog, err := eventlog.Open(name); err != nil { + // On reinstall the source already exists; skip registration to avoid a + // spurious warning. Track creation so rollback can remove it. + eventSourceCreated := false + if !eventLogSourceExists(name) { if err := eventlog.InstallAsEventCreate(name, eventlog.Error|eventlog.Warning|eventlog.Info); err != nil { fmt.Fprintf(os.Stderr, "warning: could not register event log source: %v\n", err) + } else { + eventSourceCreated = true } - } else { - elog.Close() } // Write a registry marker so uninstall can confirm this service was @@ -307,11 +376,12 @@ func doInstallService(name string, proxyArgs []string) error { } if err := waitForServiceRunning(s, name); err != nil { - if elog, elogErr := eventlog.Open(name); elogErr == nil { - _ = elog.Error(1, fmt.Sprintf("ghostunnel service install failed: %v", err)) - elog.Close() - } + err = fmt.Errorf("%w: %w", errServiceNotStarted, err) + writeEventLogError(name, fmt.Sprintf("ghostunnel service install failed: %v", err)) _ = s.Delete() + if eventSourceCreated { + _ = eventlog.Remove(name) + } return fmt.Errorf("%w; service registration has been removed, fix the issue and re-run 'service install'", err) } @@ -348,10 +418,7 @@ func doUninstallService(name string) error { return fmt.Errorf("could not delete service %q: %w", name, err) } - if elog, err := eventlog.Open(name); err == nil { - _ = elog.Info(1, fmt.Sprintf("ghostunnel service %q uninstalled", name)) - elog.Close() - } + writeEventLogInfo(name, fmt.Sprintf("ghostunnel service %q uninstalled", name)) _ = eventlog.Remove(name) // best-effort; non-critical fmt.Printf("Service %q stopped and removed successfully.\n", name) @@ -380,11 +447,11 @@ func doStartService(name string) error { } if err := s.Start(); err != nil { - return fmt.Errorf("could not start service %q: %w", name, err) + return fmt.Errorf("service %q: %w: %w", name, errServiceNotStarted, err) } if err := waitForServiceRunning(s, name); err != nil { - return err + return fmt.Errorf("%w: %w", errServiceNotStarted, err) } fmt.Printf("Service %q started successfully.\n", name) diff --git a/windows_service_test.go b/windows_service_test.go index 446b89ecc8..9be345366f 100644 --- a/windows_service_test.go +++ b/windows_service_test.go @@ -4,9 +4,13 @@ package main import ( "errors" + "fmt" + "strings" "testing" + "time" "golang.org/x/sys/windows/registry" + "golang.org/x/sys/windows/svc" ) // isWindowsAdmin reports whether the current process has Administrator @@ -87,15 +91,19 @@ func TestServiceLifecycle(t *testing.T) { // rather than ghostunnel.exe. The SCM will fail to start it as a Windows // service because testing.Main() never registers a service control handler. // We tolerate that specific error and verify the SCM registration itself. - if err := doInstallService(name, proxyArgs); err != nil { - if !errors.Is(err, errServiceNotStarted) { - t.Fatalf("install: %v", err) - } + installErr := doInstallService(name, proxyArgs) + if installErr != nil && !errors.Is(installErr, errServiceNotStarted) { + t.Fatalf("install: %v", installErr) } - // Service should be registered in the SCM regardless of whether it started. - if err := doStatusService(name); err != nil { - t.Errorf("status after install: %v", err) + // If install failed during waitForServiceRunning, the registration has + // been rolled back automatically; nothing more to test or clean up. + statusErr := doStatusService(name) + if installErr != nil && statusErr != nil { + return + } + if statusErr != nil { + t.Errorf("status after install: %v", statusErr) } // Service will be stopped already (never started); stopServiceWithTimeout @@ -113,3 +121,125 @@ func TestServiceLifecycle(t *testing.T) { t.Error("expected error querying service after uninstall, got nil") } } + +// waitForServiceRunningPoll is exercised here without a real SCM by injecting +// a scripted query function. Tests use zero or near-zero durations to keep +// runtime negligible. + +type pollStep struct { + state svc.State + err error +} + +func newScriptedQuery(steps []pollStep) func() (svc.Status, error) { + i := 0 + return func() (svc.Status, error) { + if i >= len(steps) { + return svc.Status{}, fmt.Errorf("query called more times than scripted (%d)", len(steps)) + } + step := steps[i] + i++ + return svc.Status{State: step.state}, step.err + } +} + +func TestWaitForServiceRunningPoll(t *testing.T) { + queryFailure := errors.New("scripted query failure") + + tests := []struct { + name string + steps []pollStep + wantErr bool + errSubstr string + }{ + { + name: "running immediately, stable", + steps: []pollStep{{state: svc.Running}, {state: svc.Running}}, + wantErr: false, + }, + { + name: "start pending then running", + steps: []pollStep{{state: svc.StartPending}, {state: svc.Running}, {state: svc.Running}}, + wantErr: false, + }, + { + name: "continue pending then running", + steps: []pollStep{{state: svc.ContinuePending}, {state: svc.Running}, {state: svc.Running}}, + wantErr: false, + }, + { + name: "stopped immediately", + steps: []pollStep{{state: svc.Stopped}}, + wantErr: true, + errSubstr: "failed to reach running state", + }, + { + name: "stop pending fails fast", + steps: []pollStep{{state: svc.StopPending}}, + wantErr: true, + errSubstr: "failed to reach running state", + }, + { + name: "paused fails fast", + steps: []pollStep{{state: svc.Paused}}, + wantErr: true, + errSubstr: "failed to reach running state", + }, + { + name: "running then stopped during stabilization", + steps: []pollStep{{state: svc.Running}, {state: svc.Stopped}}, + wantErr: true, + errSubstr: "failed to reach running state", + }, + { + name: "running then stop pending during stabilization", + steps: []pollStep{{state: svc.Running}, {state: svc.StopPending}}, + wantErr: true, + errSubstr: "failed to reach running state", + }, + { + name: "query error", + steps: []pollStep{{err: queryFailure}}, + wantErr: true, + errSubstr: "could not query", + }, + { + name: "query error during stabilization", + steps: []pollStep{{state: svc.Running}, {err: queryFailure}}, + wantErr: true, + errSubstr: "could not query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := newScriptedQuery(tt.steps) + // Zero poll/stabilization keep the test instantaneous; a generous + // timeout ensures the loop never trips the deadline before the + // scripted query yields a terminal state. + err := waitForServiceRunningPoll("test-service", query, 0, 0, time.Minute) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if tt.wantErr && tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("err = %q, want substring %q", err, tt.errSubstr) + } + }) + } +} + +func TestWaitForServiceRunningPollTimeout(t *testing.T) { + // Always-StartPending query forces the loop to rely on the deadline check. + // A 1ms poll bound prevents busy-looping while still keeping the test fast. + query := func() (svc.Status, error) { + return svc.Status{State: svc.StartPending}, nil + } + + err := waitForServiceRunningPoll("test-service", query, time.Millisecond, 0, 10*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("err = %q, want timeout message", err) + } +} From 6c50014edabadc2aa95a6d3ef7619d8138c7e039 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 13:49:30 -0700 Subject: [PATCH 16/23] Implement notify-ready functionality, generalize from the systemd functions, use for Windows SCM --- status.go | 14 +++++------ status_linux.go | 20 ++++++++-------- status_linux_test.go | 4 ++-- status_other.go | 22 +++++++++--------- status_test.go | 18 +++++++-------- windows_service.go | 51 ++++++++++++++++++++--------------------- windows_service_test.go | 36 ++++++++--------------------- 7 files changed, 73 insertions(+), 92 deletions(-) diff --git a/status.go b/status.go index 965ab3e01e..07e6c9b021 100644 --- a/status.go +++ b/status.go @@ -97,8 +97,8 @@ func newStatusHandler(dial proxy.DialFunc, command, listenAddress, forwardAddres } func (s *statusHandler) Listening() { - systemdNotifyReady() - systemdNotifyStatus(fmt.Sprintf("listening | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) + notifyServiceReady() + notifyServiceStatus(fmt.Sprintf("listening | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) s.mu.Lock() s.listening = true s.reloading = false @@ -106,8 +106,8 @@ func (s *statusHandler) Listening() { } func (s *statusHandler) Reloading() { - systemdNotifyReloading() - systemdNotifyStatus(fmt.Sprintf("reloading | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) + notifyServiceReloading() + notifyServiceStatus(fmt.Sprintf("reloading | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) s.mu.Lock() s.reloading = true s.lastReload = time.Now() @@ -115,8 +115,8 @@ func (s *statusHandler) Reloading() { } func (s *statusHandler) Stopping() { - systemdNotifyStopping() - systemdNotifyStatus(fmt.Sprintf("stopping | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) + notifyServiceStopping() + notifyServiceStatus(fmt.Sprintf("stopping | %s proxying %s => %s", s.command, s.listenAddress, s.forwardAddress)) s.mu.Lock() s.listening = false s.reloading = false @@ -131,7 +131,7 @@ func (s *statusHandler) HandleWatchdog() { // we can check that's useful inside the status handler. Right now, // this is good enough to report that we're not frozen. //nolint:errcheck - go systemdHandleWatchdog(func() bool { return true }, nil) + go handleServiceWatchdog(func() bool { return true }, nil) } func (s *statusHandler) status(ctx context.Context) statusResponse { diff --git a/status_linux.go b/status_linux.go index 315d29613d..e2a72f34d4 100644 --- a/status_linux.go +++ b/status_linux.go @@ -43,19 +43,19 @@ func getMonotonicUsec() (int64, error) { return (sec * 1e6) + (nsec / 1000), nil } -// systemdNotifyStatus sends a message to systemd to inform that we're ready. -func systemdNotifyStatus(status string) { +// notifyServiceStatus sends a message to systemd to inform that we're ready. +func notifyServiceStatus(status string) { msg := fmt.Sprintf("STATUS=%s", status) _, _ = daemon.SdNotify(false, msg) } -// systemdNotifyReady sends a message to systemd to inform that we're ready. -func systemdNotifyReady() { +// notifyServiceReady sends a message to systemd to inform that we're ready. +func notifyServiceReady() { _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) } -// systemdNotifyReloading sends a message to systemd to inform that we're reloading. -func systemdNotifyReloading() { +// notifyServiceReloading sends a message to systemd to inform that we're reloading. +func notifyServiceReloading() { usec, err := getMonotonicUsec() if err != nil { _, _ = daemon.SdNotify(false, daemon.SdNotifyReloading) @@ -65,13 +65,13 @@ func systemdNotifyReloading() { _, _ = daemon.SdNotify(false, msg) } -// systemdNotifyStopping sends a message to systemd to inform that we're stopping. -func systemdNotifyStopping() { +// notifyServiceStopping sends a message to systemd to inform that we're stopping. +func notifyServiceStopping() { _, _ = daemon.SdNotify(false, daemon.SdNotifyStopping) } -// systemdHandleWatchdog sends watchdog messages to systemd to keep us alive, if enabled. -func systemdHandleWatchdog(isHealthy func() bool, shutdown chan bool) error { +// handleServiceWatchdog sends watchdog messages to systemd to keep us alive, if enabled. +func handleServiceWatchdog(isHealthy func() bool, shutdown chan bool) error { dur, err := daemon.SdWatchdogEnabled(false) if err != nil { return err diff --git a/status_linux_test.go b/status_linux_test.go index cf74af5c6d..1a4b1dbe9b 100644 --- a/status_linux_test.go +++ b/status_linux_test.go @@ -14,7 +14,7 @@ func TestGetMonotonicUsec(t *testing.T) { } } -func TestSystemdNotifyReloadingDoesNotPanic(t *testing.T) { +func TestNotifyServiceReloadingDoesNotPanic(t *testing.T) { // Should not panic even if systemd is not available - systemdNotifyReloading() + notifyServiceReloading() } diff --git a/status_other.go b/status_other.go index 3838a4a85c..1a6ea05a85 100644 --- a/status_other.go +++ b/status_other.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux && !windows /*- * Copyright 2024, Ghostunnel @@ -18,19 +18,19 @@ package main -// systemdNotifyStatus sends a message to systemd to inform that we're ready. -func systemdNotifyStatus(_ string) {} +// notifyServiceStatus sends a message to systemd to inform that we're ready. +func notifyServiceStatus(_ string) {} -// systemdNotifyReady sends a message to systemd to inform that we're ready. -func systemdNotifyReady() {} +// notifyServiceReady sends a message to systemd to inform that we're ready. +func notifyServiceReady() {} -// systemdNotifyReloading sends a message to systemd to inform that we're reloading. -func systemdNotifyReloading() {} +// notifyServiceReloading sends a message to systemd to inform that we're reloading. +func notifyServiceReloading() {} -// systemdNotifyStopping sends a message to systemd to inform that we're stopping. -func systemdNotifyStopping() {} +// notifyServiceStopping sends a message to systemd to inform that we're stopping. +func notifyServiceStopping() {} -// systemdHandleWatchdog sends watchdog messages to systemd to keep us alive, if enabled. -func systemdHandleWatchdog(isHealthy func() bool, shutdown chan bool) error { +// handleServiceWatchdog sends watchdog messages to systemd to keep us alive, if enabled. +func handleServiceWatchdog(isHealthy func() bool, shutdown chan bool) error { return nil } diff --git a/status_test.go b/status_test.go index 08dc2ff446..9bcca935ea 100644 --- a/status_test.go +++ b/status_test.go @@ -79,9 +79,9 @@ func TestStatusHandleWatchdogError(t *testing.T) { defer os.Unsetenv("WATCHDOG_PID") defer os.Unsetenv("WATCHDOG_USEC") - err := systemdHandleWatchdog(func() bool { return true }, nil) + err := handleServiceWatchdog(func() bool { return true }, nil) if err == nil { - t.Error("systemdHandleWatchdog did not handle invalid watchdog settings correctly") + t.Error("handleServiceWatchdog did not handle invalid watchdog settings correctly") } } @@ -101,7 +101,7 @@ func TestStatusHandleWatchdog(t *testing.T) { shutdown := make(chan bool, 1) done := make(chan bool, 1) go func() { - err := systemdHandleWatchdog(func() bool { + err := handleServiceWatchdog(func() bool { // Send shutdown signal to stop handler shutdown <- true return true @@ -269,14 +269,14 @@ func TestSystemdStubsAreNoOps(t *testing.T) { } // These should not panic - systemdNotifyStatus("test") - systemdNotifyReady() - systemdNotifyReloading() - systemdNotifyStopping() + notifyServiceStatus("test") + notifyServiceReady() + notifyServiceReloading() + notifyServiceStopping() - err := systemdHandleWatchdog(func() bool { return true }, nil) + err := handleServiceWatchdog(func() bool { return true }, nil) if err != nil { - t.Errorf("systemdHandleWatchdog stub should return nil, got: %v", err) + t.Errorf("handleServiceWatchdog stub should return nil, got: %v", err) } } diff --git a/windows_service.go b/windows_service.go index 9a4e36a602..5635ecc3dd 100644 --- a/windows_service.go +++ b/windows_service.go @@ -45,10 +45,6 @@ const ( // serviceStatePollInterval is the delay between SCM status queries while // waiting for a state transition. serviceStatePollInterval = 300 * time.Millisecond - // runningStabilizationDelay is the brief pause after observing Running to - // catch services that crash immediately after reporting Running (e.g. a - // flag parse failure that calls os.Exit). - runningStabilizationDelay = 300 * time.Millisecond // failedToStartMsg is the format string used whenever a service does not // reach Running, regardless of which terminal state was observed. The @@ -130,13 +126,25 @@ func (s *ghostunnelService) Execute(_ []string, r <-chan svc.ChangeRequest, chan done <- run(os.Args[1:]) }() - // TODO: run() was launched in a goroutine above but may not have finished - // binding ports yet. Ideally we'd wait for a readiness signal before - // reporting Running to the SCM. This requires a larger refactor to thread - // a readiness callback through the startup path. - changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} - if elog != nil { - _ = elog.Info(1, "ghostunnel service started") + // Defer the Running transition until the proxy actually starts listening + // (signaled via notifyServiceReady, called from statusHandler.Listening). + // If run() exits early or never reaches ready, fail the start. + select { + case <-serviceReadyChan(): + changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + if elog != nil { + _ = elog.Info(1, "ghostunnel service started") + } + case err := <-done: + if elog != nil { + _ = elog.Error(1, fmt.Sprintf("ghostunnel exited before reaching ready: %v", err)) + } + return false, 1 + case <-time.After(serviceStateChangeTimeout): + if elog != nil { + _ = elog.Error(1, "ghostunnel did not reach ready state within timeout") + } + return false, 1 } for { @@ -239,19 +247,20 @@ func writeEventLogError(name, msg string) { // waitForServiceRunning polls the SCM after a start command and waits for the // service to reach the Running state, returning an error if the service // transitions to a terminal state or does not reach Running within -// serviceStateChangeTimeout. +// serviceStateChangeTimeout. Because Execute now defers the Running transition +// until the proxy is actually accepting connections, observing Running here +// means startup truly succeeded. func waitForServiceRunning(s *mgr.Service, name string) error { - return waitForServiceRunningPoll(name, s.Query, serviceStatePollInterval, runningStabilizationDelay, serviceStateChangeTimeout) + return waitForServiceRunningPoll(name, s.Query, serviceStatePollInterval, serviceStateChangeTimeout) } // waitForServiceRunningPoll is the testable core of waitForServiceRunning. The -// poll interval, stabilization delay, and timeout are passed in so tests can -// use zero or near-zero durations to keep test runtime short. +// poll interval and timeout are passed in so tests can use zero or near-zero +// durations to keep test runtime short. func waitForServiceRunningPoll( name string, query func() (svc.Status, error), pollInterval time.Duration, - stabilizationDelay time.Duration, timeout time.Duration, ) error { deadline := time.Now().Add(timeout) @@ -262,16 +271,6 @@ func waitForServiceRunningPoll( } switch status.State { case svc.Running: - // Brief stabilization: the service may crash immediately after - // reporting Running (e.g. flag parse failure calls os.Exit). - time.Sleep(stabilizationDelay) - status, err = query() - if err != nil { - return fmt.Errorf("could not query service %q status: %w", name, err) - } - if status.State != svc.Running { - return fmt.Errorf(failedToStartMsg, name) - } return nil case svc.StartPending, svc.ContinuePending: // Still transitioning; keep polling. diff --git a/windows_service_test.go b/windows_service_test.go index 9be345366f..dfda0a6438 100644 --- a/windows_service_test.go +++ b/windows_service_test.go @@ -153,18 +153,18 @@ func TestWaitForServiceRunningPoll(t *testing.T) { errSubstr string }{ { - name: "running immediately, stable", - steps: []pollStep{{state: svc.Running}, {state: svc.Running}}, + name: "running immediately", + steps: []pollStep{{state: svc.Running}}, wantErr: false, }, { name: "start pending then running", - steps: []pollStep{{state: svc.StartPending}, {state: svc.Running}, {state: svc.Running}}, + steps: []pollStep{{state: svc.StartPending}, {state: svc.Running}}, wantErr: false, }, { name: "continue pending then running", - steps: []pollStep{{state: svc.ContinuePending}, {state: svc.Running}, {state: svc.Running}}, + steps: []pollStep{{state: svc.ContinuePending}, {state: svc.Running}}, wantErr: false, }, { @@ -185,39 +185,21 @@ func TestWaitForServiceRunningPoll(t *testing.T) { wantErr: true, errSubstr: "failed to reach running state", }, - { - name: "running then stopped during stabilization", - steps: []pollStep{{state: svc.Running}, {state: svc.Stopped}}, - wantErr: true, - errSubstr: "failed to reach running state", - }, - { - name: "running then stop pending during stabilization", - steps: []pollStep{{state: svc.Running}, {state: svc.StopPending}}, - wantErr: true, - errSubstr: "failed to reach running state", - }, { name: "query error", steps: []pollStep{{err: queryFailure}}, wantErr: true, errSubstr: "could not query", }, - { - name: "query error during stabilization", - steps: []pollStep{{state: svc.Running}, {err: queryFailure}}, - wantErr: true, - errSubstr: "could not query", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { query := newScriptedQuery(tt.steps) - // Zero poll/stabilization keep the test instantaneous; a generous - // timeout ensures the loop never trips the deadline before the - // scripted query yields a terminal state. - err := waitForServiceRunningPoll("test-service", query, 0, 0, time.Minute) + // Zero poll keeps the test instantaneous; a generous timeout + // ensures the loop never trips the deadline before the scripted + // query yields a terminal state. + err := waitForServiceRunningPoll("test-service", query, 0, time.Minute) if (err != nil) != tt.wantErr { t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) } @@ -235,7 +217,7 @@ func TestWaitForServiceRunningPollTimeout(t *testing.T) { return svc.Status{State: svc.StartPending}, nil } - err := waitForServiceRunningPoll("test-service", query, time.Millisecond, 0, 10*time.Millisecond) + err := waitForServiceRunningPoll("test-service", query, time.Millisecond, 10*time.Millisecond) if err == nil { t.Fatal("expected timeout error, got nil") } From 094477f3d48e096c11988ccc27f65bc5201297d3 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 20:03:31 -0700 Subject: [PATCH 17/23] Better Windows SCM tracking for running status --- status_linux.go | 2 +- status_other.go | 20 ++++++++------------ status_test.go | 4 ++-- windows_service.go | 36 ++++++++++++++++++++++++++++++++++-- windows_service_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/status_linux.go b/status_linux.go index e2a72f34d4..c4fceac6c5 100644 --- a/status_linux.go +++ b/status_linux.go @@ -43,7 +43,7 @@ func getMonotonicUsec() (int64, error) { return (sec * 1e6) + (nsec / 1000), nil } -// notifyServiceStatus sends a message to systemd to inform that we're ready. +// notifyServiceStatus sends a free-form status message to systemd via sd_notify. func notifyServiceStatus(status string) { msg := fmt.Sprintf("STATUS=%s", status) _, _ = daemon.SdNotify(false, msg) diff --git a/status_other.go b/status_other.go index 1a6ea05a85..eb8de367f9 100644 --- a/status_other.go +++ b/status_other.go @@ -18,19 +18,15 @@ package main -// notifyServiceStatus sends a message to systemd to inform that we're ready. -func notifyServiceStatus(_ string) {} - -// notifyServiceReady sends a message to systemd to inform that we're ready. -func notifyServiceReady() {} +// These are no-op stubs for platforms without a service-manager integration +// (e.g. macOS, FreeBSD when not run under launchd/rc.d). Linux and Windows +// have their own implementations that route to systemd or the SCM. -// notifyServiceReloading sends a message to systemd to inform that we're reloading. -func notifyServiceReloading() {} - -// notifyServiceStopping sends a message to systemd to inform that we're stopping. -func notifyServiceStopping() {} +func notifyServiceStatus(_ string) {} +func notifyServiceReady() {} +func notifyServiceReloading() {} +func notifyServiceStopping() {} -// handleServiceWatchdog sends watchdog messages to systemd to keep us alive, if enabled. -func handleServiceWatchdog(isHealthy func() bool, shutdown chan bool) error { +func handleServiceWatchdog(_ func() bool, _ chan bool) error { return nil } diff --git a/status_test.go b/status_test.go index 9bcca935ea..83ddc01390 100644 --- a/status_test.go +++ b/status_test.go @@ -263,9 +263,9 @@ func TestServeHTTPReturnsJSON(t *testing.T) { } } -func TestSystemdStubsAreNoOps(t *testing.T) { +func TestNonLinuxNotifyHelpersDoNotPanic(t *testing.T) { if runtime.GOOS == "linux" { - t.Skip("stubs are only compiled on non-Linux") + t.Skip("Linux uses the real systemd implementation") } // These should not panic diff --git a/windows_service.go b/windows_service.go index 5635ecc3dd..9def5a050c 100644 --- a/windows_service.go +++ b/windows_service.go @@ -73,6 +73,34 @@ var ( serviceInstallArgs = serviceInstallCmd.Arg("args", "Proxy arguments to pass to the service, separated from service flags by '--' (e.g. -- server --listen :8443 --target localhost:8080).").Strings() ) +// serviceReadyCh is signaled the first time notifyServiceReady is called, +// allowing ghostunnelService.Execute to defer the SCM Running transition +// until the proxy is actually accepting connections. +var serviceReadyCh = make(chan struct{}, 1) + +// notifyServiceReady signals that the proxy has started listening. Idempotent: +// subsequent calls (e.g. on reload) are dropped because the channel is buffered +// and the send is non-blocking. +func notifyServiceReady() { + select { + case serviceReadyCh <- struct{}{}: + default: + } +} + +// notifyServiceStatus, notifyServiceReloading, notifyServiceStopping have no +// SCM equivalent: Windows services report state transitions via the changes +// channel in Execute, not through ambient notifications. +func notifyServiceStatus(_ string) {} +func notifyServiceReloading() {} +func notifyServiceStopping() {} + +// handleServiceWatchdog is a no-op on Windows. SCM tracks service liveness via +// process exit, not via periodic pings, so there is nothing to send. +func handleServiceWatchdog(_ func() bool, _ chan bool) error { + return nil +} + // currentServiceName discovers the name of the Windows service for the current // process by matching PIDs via the SCM. Returns defaultServiceName on failure. // This iterates all registered services, which may be slow on systems with many @@ -130,14 +158,18 @@ func (s *ghostunnelService) Execute(_ []string, r <-chan svc.ChangeRequest, chan // (signaled via notifyServiceReady, called from statusHandler.Listening). // If run() exits early or never reaches ready, fail the start. select { - case <-serviceReadyChan(): + case <-serviceReadyCh: changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} if elog != nil { _ = elog.Info(1, "ghostunnel service started") } case err := <-done: if elog != nil { - _ = elog.Error(1, fmt.Sprintf("ghostunnel exited before reaching ready: %v", err)) + msg := "ghostunnel exited before reaching ready" + if err != nil { + msg = fmt.Sprintf("%s: %v", msg, err) + } + _ = elog.Error(1, msg) } return false, 1 case <-time.After(serviceStateChangeTimeout): diff --git a/windows_service_test.go b/windows_service_test.go index dfda0a6438..c1feb2260f 100644 --- a/windows_service_test.go +++ b/windows_service_test.go @@ -225,3 +225,44 @@ func TestWaitForServiceRunningPollTimeout(t *testing.T) { t.Errorf("err = %q, want timeout message", err) } } + +func TestNotifyServiceReadySignalsChannel(t *testing.T) { + // Drain any prior signal so the test is independent of run order. + select { + case <-serviceReadyCh: + default: + } + + notifyServiceReady() + + select { + case <-serviceReadyCh: + case <-time.After(time.Second): + t.Fatal("notifyServiceReady did not signal serviceReadyCh within 1s") + } +} + +func TestNotifyServiceReadyIsIdempotent(t *testing.T) { + // Drain any prior signal. + select { + case <-serviceReadyCh: + default: + } + + // Multiple calls must not block even though only one fits in the buffer. + for i := 0; i < 5; i++ { + notifyServiceReady() + } + + // Exactly one signal should be buffered (the rest dropped). + select { + case <-serviceReadyCh: + case <-time.After(time.Second): + t.Fatal("expected at least one buffered ready signal") + } + select { + case <-serviceReadyCh: + t.Fatal("expected only one buffered ready signal, got more") + case <-time.After(50 * time.Millisecond): + } +} From 8a2f6424cc6f9b7acc435eafdfebce9f2574d709 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 20:14:36 -0700 Subject: [PATCH 18/23] Document service subcommands in flags reference Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/getting-started/flags.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/getting-started/flags.md b/docs/getting-started/flags.md index 72c4dbb572..b2f61dea2e 100644 --- a/docs/getting-started/flags.md +++ b/docs/getting-started/flags.md @@ -197,6 +197,33 @@ See [Access Control Flags]({{< ref "access-flags.md" >}}) for OPA/Rego policy de | `--verify-policy BUNDLE` | Location of an OPA policy bundle. | | `--verify-query QUERY` | Rego query to evaluate against the server certificate and the policy. | +## Service Subcommands (Windows) + +Manage Ghostunnel as a native Windows service via the Service Control Manager. +All `service` subcommands require **Administrator** privileges and are only +available on Windows. See [Windows Service]({{< ref "windows-service.md" >}}) for +the full guide. + +### Subcommands + +| Subcommand | Description | +|------------|-------------| +| `service install [--service-name NAME] -- ARGS...` | Register, configure, and start the service. Proxy arguments follow `--` (e.g. `-- server --listen :8443 --target localhost:8080`). | +| `service uninstall [--service-name NAME]` | Stop and remove the service. Refuses to remove services not installed by Ghostunnel. | +| `service start [--service-name NAME]` | Start an existing stopped service. | +| `service stop [--service-name NAME]` | Gracefully stop a running service. | +| `service status [--service-name NAME]` | Show the current service state. | + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--service-name NAME` | `ghostunnel` | Name to use for the Windows service. May contain letters, digits, hyphens, underscores, and spaces (max 256 characters). | + +To send service logs to the Windows Event Log instead of stdout, pass +`--eventlog` in the proxy arguments after `--`. See `--eventlog` under +[Status / Logging](#status--logging). + ## Environment Variables Several flags can also be set via environment variables. From dcc4a1f3ab6838923f1aaa0055c925bf5c4807f0 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 20:15:42 -0700 Subject: [PATCH 19/23] Update CONTRIBUTING.md integration test coverage description Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91fdde65cc..2732b06263 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,9 +100,13 @@ GHOSTUNNEL_TEST_PARALLEL=4 go tool mage test:integration # control parallelism Integration tests run in parallel by default (up to `NumCPU`, capped at 16). Set `GHOSTUNNEL_TEST_PARALLEL` to control the number of concurrent tests (may exceed the default cap). -The integration test runner first builds a coverage-instrumented test binary -(`go test -c`), then runs each `tests/test-*.py` script against that binary. -Each Python test starts a ghostunnel process, exercises it, and verifies behavior. +The integration test runner first builds a coverage-instrumented binary with +`go build -cover -tags coverage` (a normal binary, not a `go test -c` test +binary). Each Python test starts that binary with `GOCOVERDIR` pointing at a +per-run directory; the `coverage` build tag (see `coverage_enabled.go`) flushes +counters on exit so data survives signal-triggered shutdowns. After the run, +`go tool covdata textfmt` converts the binary coverage data to a text profile, +which is then merged with the unit-test profile. ## Architecture Overview From 2d2e286b60d6d37534941fbb578086edec3f8960 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 20:20:08 -0700 Subject: [PATCH 20/23] Expand launchd docs with KeepAlive, reload, and shutdown notes Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/deployment/launchd.md | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/deployment/launchd.md b/docs/deployment/launchd.md index 97727f1874..113383c926 100644 --- a/docs/deployment/launchd.md +++ b/docs/deployment/launchd.md @@ -9,6 +9,9 @@ Launchd socket activation is supported for the `--listen` and `--status` flags by passing an address of the form `launchd:`, where `` matches the socket key defined in your plist. +On macOS, Ghostunnel can also load TLS identities directly from the system +keychain via `--keychain-identity`. See [Keychain]({{< ref "keychain.md" >}}). + ## Example Plist A launchd plist to run Ghostunnel in server mode, listening on `:8081`, @@ -38,6 +41,10 @@ with a status port on `:8082`, forwarding connections to `:8083`: --allow-cn client + RunAtLoad + + KeepAlive + StandardOutPath /var/log/ghostunnel.out.log StandardErrorPath @@ -67,6 +74,10 @@ with a status port on `:8082`, forwarding connections to `:8083`: ``` +`RunAtLoad` starts the service when the plist is bootstrapped (or at boot for +system daemons). `KeepAlive` restarts the process if it exits unexpectedly, +equivalent to systemd's `Restart=always`. + Both `SockType` and `SockFamily` must be defined for each socket. If the family is omitted, launchd opens two sockets (IPv4 and IPv6) for each key, which Ghostunnel does not currently support. @@ -90,4 +101,32 @@ On older macOS versions (before 10.11), use `launchctl load` and Use `~/Library/LaunchAgents/` (with `gui//` instead of `system/`) if running as a user agent rather than a system daemon. +## Reloading Certificates + +To reload certificates without restarting the service, send `SIGHUP`: + +```bash +sudo launchctl kill SIGHUP system/ghostunnel +``` + +This triggers the same certificate reload as sending `SIGHUP` to the process +directly. + +For automatic periodic reloads (e.g. with short-lived certificates), pass +`--timed-reload DURATION` in the plist's `ProgramArguments`. Ghostunnel +re-reads the keystore at that interval and refreshes the listener if the +certificate changed, without needing `SIGHUP`. + +## Graceful Shutdown + +By default, launchd waits 20 seconds between sending `SIGTERM` and force-killing +the process with `SIGKILL`. If Ghostunnel's `--shutdown-timeout` (default `5m`) +exceeds that window, in-flight connections will be cut off when launchd kills +the process. To allow the full drain window, raise `ExitTimeOut` in the plist: + +```xml +ExitTimeOut +360 +``` + [launchd-guide]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html From 385e1f8d1ea1abc40af47c1f7026737b67adb72d Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Tue, 28 Apr 2026 20:27:04 -0700 Subject: [PATCH 21/23] Better wording for new docs --- CONTRIBUTING.md | 12 ++++++------ docs/deployment/launchd.md | 17 +++++++---------- docs/getting-started/flags.md | 9 ++++----- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2732b06263..c412c2f711 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,12 +101,12 @@ Integration tests run in parallel by default (up to `NumCPU`, capped at 16). Set `GHOSTUNNEL_TEST_PARALLEL` to control the number of concurrent tests (may exceed the default cap). The integration test runner first builds a coverage-instrumented binary with -`go build -cover -tags coverage` (a normal binary, not a `go test -c` test -binary). Each Python test starts that binary with `GOCOVERDIR` pointing at a -per-run directory; the `coverage` build tag (see `coverage_enabled.go`) flushes -counters on exit so data survives signal-triggered shutdowns. After the run, -`go tool covdata textfmt` converts the binary coverage data to a text profile, -which is then merged with the unit-test profile. +`go build -cover -tags coverage`. Each Python test starts that binary with +`GOCOVERDIR` pointing at a per-run directory; the `coverage` build tag (see +`coverage_enabled.go`) flushes counters on exit so data survives +signal-triggered shutdowns. After the run, `go tool covdata textfmt` converts +the binary coverage data to a text profile, which is then merged with the +unit-test profile. ## Architecture Overview diff --git a/docs/deployment/launchd.md b/docs/deployment/launchd.md index 113383c926..69a66a9b68 100644 --- a/docs/deployment/launchd.md +++ b/docs/deployment/launchd.md @@ -9,8 +9,8 @@ Launchd socket activation is supported for the `--listen` and `--status` flags by passing an address of the form `launchd:`, where `` matches the socket key defined in your plist. -On macOS, Ghostunnel can also load TLS identities directly from the system -keychain via `--keychain-identity`. See [Keychain]({{< ref "keychain.md" >}}). +Ghostunnel can also load TLS identities from the system keychain via +`--keychain-identity`. See [Keychain]({{< ref "keychain.md" >}}). ## Example Plist @@ -109,20 +109,17 @@ To reload certificates without restarting the service, send `SIGHUP`: sudo launchctl kill SIGHUP system/ghostunnel ``` -This triggers the same certificate reload as sending `SIGHUP` to the process -directly. - For automatic periodic reloads (e.g. with short-lived certificates), pass `--timed-reload DURATION` in the plist's `ProgramArguments`. Ghostunnel re-reads the keystore at that interval and refreshes the listener if the -certificate changed, without needing `SIGHUP`. +certificate changed. ## Graceful Shutdown -By default, launchd waits 20 seconds between sending `SIGTERM` and force-killing -the process with `SIGKILL`. If Ghostunnel's `--shutdown-timeout` (default `5m`) -exceeds that window, in-flight connections will be cut off when launchd kills -the process. To allow the full drain window, raise `ExitTimeOut` in the plist: +By default, launchd waits 20 seconds between `SIGTERM` and `SIGKILL`. If +Ghostunnel's `--shutdown-timeout` (default `5m`) exceeds that window, in-flight +connections will be cut off. To allow the full drain window, raise +`ExitTimeOut` in the plist: ```xml ExitTimeOut diff --git a/docs/getting-started/flags.md b/docs/getting-started/flags.md index b2f61dea2e..cc34d50cd7 100644 --- a/docs/getting-started/flags.md +++ b/docs/getting-started/flags.md @@ -200,15 +200,14 @@ See [Access Control Flags]({{< ref "access-flags.md" >}}) for OPA/Rego policy de ## Service Subcommands (Windows) Manage Ghostunnel as a native Windows service via the Service Control Manager. -All `service` subcommands require **Administrator** privileges and are only -available on Windows. See [Windows Service]({{< ref "windows-service.md" >}}) for -the full guide. +All `service` subcommands require **Administrator** privileges. See +[Windows Service]({{< ref "windows-service.md" >}}) for the full guide. ### Subcommands | Subcommand | Description | |------------|-------------| -| `service install [--service-name NAME] -- ARGS...` | Register, configure, and start the service. Proxy arguments follow `--` (e.g. `-- server --listen :8443 --target localhost:8080`). | +| `service install [--service-name NAME] -- ARGS...` | Install and start the service. Proxy arguments follow `--` (e.g. `-- server --listen :8443 --target localhost:8080`). | | `service uninstall [--service-name NAME]` | Stop and remove the service. Refuses to remove services not installed by Ghostunnel. | | `service start [--service-name NAME]` | Start an existing stopped service. | | `service stop [--service-name NAME]` | Gracefully stop a running service. | @@ -221,7 +220,7 @@ the full guide. | `--service-name NAME` | `ghostunnel` | Name to use for the Windows service. May contain letters, digits, hyphens, underscores, and spaces (max 256 characters). | To send service logs to the Windows Event Log instead of stdout, pass -`--eventlog` in the proxy arguments after `--`. See `--eventlog` under +`--eventlog` in the proxy arguments after `--`. See [Status / Logging](#status--logging). ## Environment Variables From 843d09eca0550796990846949e4698117ba04047 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 29 Apr 2026 09:04:06 -0700 Subject: [PATCH 22/23] Mention that v1.11 is the next release --- docs/deployment/windows-service.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment/windows-service.md b/docs/deployment/windows-service.md index 52c3d90b01..93d5051212 100644 --- a/docs/deployment/windows-service.md +++ b/docs/deployment/windows-service.md @@ -4,7 +4,7 @@ description: Install and manage Ghostunnel as a native Windows service via the S weight: 25 --- -*Available since v1.11.0.* +*This feature will be available in the next major release, v1.11.0* Ghostunnel can run as a native Windows service managed by the [Service Control Manager][scm] (SCM). The `ghostunnel service` subcommands From 4677fcc40cf45eee33d68002670f54c8623a3af9 Mon Sep 17 00:00:00 2001 From: Cedric Staub Date: Wed, 29 Apr 2026 09:18:43 -0700 Subject: [PATCH 23/23] Add Docker link in menu, add icons --- website/hugo.toml | 16 ++++++++++++++++ website/layouts/partials/icon-contact.html | 1 + website/layouts/partials/icon-contributors.html | 1 + website/layouts/partials/icon-docs.html | 1 + website/layouts/partials/icon-releases.html | 1 + website/layouts/partials/nav.html | 2 +- website/static/css/style.css | 7 ++++++- 7 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 website/layouts/partials/icon-contact.html create mode 100644 website/layouts/partials/icon-contributors.html create mode 100644 website/layouts/partials/icon-docs.html create mode 100644 website/layouts/partials/icon-releases.html diff --git a/website/hugo.toml b/website/hugo.toml index e6d5662a4d..4ff209cf63 100644 --- a/website/hugo.toml +++ b/website/hugo.toml @@ -29,22 +29,38 @@ enableGitInfo = true name = "Docs" url = "/docs/" weight = 1 + [menu.main.params] + icon = "icon-docs" [[menu.main]] name = "Releases" url = "/releases/" weight = 2 + [menu.main.params] + icon = "icon-releases" [[menu.main]] name = "Contributors" url = "/contributors/" weight = 3 + [menu.main.params] + icon = "icon-contributors" [[menu.main]] name = "Contact" url = "/contact/" weight = 4 + [menu.main.params] + icon = "icon-contact" [[menu.main]] name = "GitHub" url = "https://github.com/ghostunnel/ghostunnel" weight = 5 + [menu.main.params] + icon = "icon-github" + [[menu.main]] + name = "Docker" + url = "https://hub.docker.com/r/ghostunnel/ghostunnel" + weight = 6 + [menu.main.params] + icon = "icon-docker" [markup.goldmark.renderer] unsafe = true diff --git a/website/layouts/partials/icon-contact.html b/website/layouts/partials/icon-contact.html new file mode 100644 index 0000000000..2cd2d6c8c1 --- /dev/null +++ b/website/layouts/partials/icon-contact.html @@ -0,0 +1 @@ + diff --git a/website/layouts/partials/icon-contributors.html b/website/layouts/partials/icon-contributors.html new file mode 100644 index 0000000000..445071f638 --- /dev/null +++ b/website/layouts/partials/icon-contributors.html @@ -0,0 +1 @@ + diff --git a/website/layouts/partials/icon-docs.html b/website/layouts/partials/icon-docs.html new file mode 100644 index 0000000000..efb291ed73 --- /dev/null +++ b/website/layouts/partials/icon-docs.html @@ -0,0 +1 @@ + diff --git a/website/layouts/partials/icon-releases.html b/website/layouts/partials/icon-releases.html new file mode 100644 index 0000000000..a510dd8d51 --- /dev/null +++ b/website/layouts/partials/icon-releases.html @@ -0,0 +1 @@ + diff --git a/website/layouts/partials/nav.html b/website/layouts/partials/nav.html index 07daa9b7eb..29ac6412cf 100644 --- a/website/layouts/partials/nav.html +++ b/website/layouts/partials/nav.html @@ -10,7 +10,7 @@ diff --git a/website/static/css/style.css b/website/static/css/style.css index 6511002d9d..1ee530901a 100644 --- a/website/static/css/style.css +++ b/website/static/css/style.css @@ -194,6 +194,11 @@ a:hover { opacity: 0.8; font-size: 0.9rem; font-weight: 400; + display: inline-flex; + align-items: center; + gap: 0.4em; + transform: translateZ(0); + will-change: opacity; transition: opacity 0.15s ease, color 0.15s ease; } @@ -206,7 +211,7 @@ a:hover { .icon { width: 1em; height: 1em; - vertical-align: -0.125em; + flex-shrink: 0; } .nav-toggle {