Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ Both listeners are served by one process, and the panel exits when it cannot loa
it is configured with. `enable` therefore verifies that the panel comes back up serving exactly the
certificate it just wrote, and restores the previous `config.env` when it does not.

That check goes to the address the panel binds, not to loopback. The panel listens on
`HTTP_BIND_IP` when that is set, otherwise on `HTTP_HOST` when it holds an address of this machine,
and otherwise on every interface: `HTTP_HOST` empty, `0.0.0.0`, or a name that does not resolve to
one of the machine's own addresses. `enable`, `disable` and `status` all check it there and fall
back to loopback, so an installation configured with its public address is verified where it
actually answers. `status` prints the address it worked out.

| | system scope | user scope |
|---|---|---|
| Certificate | `/etc/gameap/certs/panel.crt` | `~/.config/gameap/certs/panel.crt` |
Expand Down
20 changes: 17 additions & 3 deletions internal/actions/panel/https/disable.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"io/fs"
"log"
"net"
"os"

panelletsencrypt "github.com/gameap/gameapctl/internal/actions/panel/letsencrypt"
Expand Down Expand Up @@ -41,6 +42,10 @@ func Disable(cliCtx *cli.Context) error {
return nil
}

// Nothing this command writes touches HTTP_HOST or HTTP_BIND_IP, so the
// values read above still describe where the restarted panel will listen.
bind := panel.ResolveBindAddress(ctx, values)

if err = configenv.Update(configPath, lines, disableUpdates(values)); err != nil {
return errors.WithMessage(err, "failed to write config")
}
Expand All @@ -51,7 +56,7 @@ func Disable(cliCtx *cli.Context) error {
return errors.WithMessage(err, "failed to restart gameap")
}

if err = waitForHTTP(ctx, values); err != nil {
if err = waitForHTTP(ctx, bind, values); err != nil {
return err
}

Expand Down Expand Up @@ -80,14 +85,23 @@ func disableUpdates(values map[string]string) map[string]string {
return updates
}

func waitForHTTP(ctx context.Context, values map[string]string) error {
func waitForHTTP(ctx context.Context, bind panel.BindAddress, values map[string]string) error {
httpPort := panel.ConfigValue(values, panel.HTTPPortKey)
if httpPort == "" {
httpPort = gameap.DefaultPanelPort
}

hosts := bind.ProbeHosts()

err := waitFor(ctx, healthInterval, func() error {
return panelpkg.CheckInstallationV4(ctx, "127.0.0.1", httpPort, false)
return panel.ProbeEach(hosts, func(host string) error {
// The health check reports a bad response without saying which
// address gave it, and here there may be more than one.
return errors.WithMessage(
panelpkg.CheckInstallationV4(ctx, host, httpPort, false),
net.JoinHostPort(host, httpPort),
)
})
})
if err != nil {
return errors.WithMessagef(err, "the panel is not answering on HTTP port %s", httpPort)
Expand Down
92 changes: 69 additions & 23 deletions internal/actions/panel/https/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ func Enable(cliCtx *cli.Context) error {
)
}

// Resolving from the values read above stays correct because nothing this
// command writes touches HTTP_HOST or HTTP_BIND_IP.
bind := panel.ResolveBindAddress(ctx, values)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

log.Println("The panel listens on:", bind)

setup, err := prepareCertificate(ctx, cliCtx, paths, values)
if err != nil {
return err
Expand All @@ -70,7 +76,7 @@ func Enable(cliCtx *cli.Context) error {
return err
}

if err = checkHTTPSPort(values, httpsPort, paths.Scope); err != nil {
if err = checkHTTPSPort(values, bind, httpsPort, paths.Scope); err != nil {
return err
}

Expand All @@ -82,11 +88,11 @@ func Enable(cliCtx *cli.Context) error {

log.Println("config.env updated. Restarting gameap ...")

if err = restartAndVerify(ctx, paths, httpsPort, setup.leaf); err != nil {
if err = restartAndVerify(ctx, paths, bind, httpsPort, setup.leaf); err != nil {
return rollback(ctx, paths, lines, err)
}

reportEnabled(cliCtx, values, setup, httpsPort)
reportEnabled(cliCtx, values, bind, setup, httpsPort)

return nil
}
Expand Down Expand Up @@ -221,20 +227,26 @@ func forceHTTPSUpdate(cliCtx *cli.Context) *bool {
// checkHTTPSPort probes the port only when the panel is not serving it already:
// a rerun that keeps the port would otherwise fail against the panel's own
// listener.
func checkHTTPSPort(values map[string]string, httpsPort, scope string) error {
func checkHTTPSPort(values map[string]string, bind panel.BindAddress, httpsPort, scope string) error {
if panel.TLSEnabled(values) && panel.HTTPSPort(values) == httpsPort {
return nil
}

if err := utils.CheckPortAvailability("", httpsPort); err != nil {
return errors.WithMessage(err, portUnavailableMessage(httpsPort, scope))
// Testing the address the panel binds rather than the wildcard also catches a
// bind address this machine does not have, which the panel would exit over.
if err := utils.CheckPortAvailability(bind.ListenAddr(), httpsPort); err != nil {
return errors.WithMessage(err, portUnavailableMessage(bind, httpsPort, scope))
}

return nil
}

func portUnavailableMessage(httpsPort, scope string) string {
message := fmt.Sprintf("port %s is not available", httpsPort)
func portUnavailableMessage(bind panel.BindAddress, httpsPort, scope string) string {
message := fmt.Sprintf("port %s is not available on %s", httpsPort, bind)

if !bind.Wildcard() {
message += " (" + bind.Key + ")"
}

port, err := strconv.Atoi(httpsPort)
if err == nil && port < privilegedPortLimit && scope == gameap.ScopeUser {
Expand Down Expand Up @@ -269,33 +281,43 @@ func warnUnreachablePath(paths gameap.PanelPaths, path string) {
}

func restartAndVerify(
ctx context.Context, paths gameap.PanelPaths, httpsPort string, expected *x509.Certificate,
ctx context.Context,
paths gameap.PanelPaths,
bind panel.BindAddress,
httpsPort string,
expected *x509.Certificate,
) error {
if err := panel.Restart(ctx, panel.Options{Scope: paths.Scope}); err != nil {
return errors.WithMessage(err, "failed to restart gameap")
}

return waitForHTTPS(ctx, httpsPort, expected)
return waitForHTTPS(ctx, bind, httpsPort, expected)
}

// waitForHTTPS blocks until the panel answers a handshake with the certificate
// that was just configured. This is not a nicety: the panel exits when it cannot
// load the configured certificate, taking the plain HTTP listener down with it,
// so an unverified change can leave the installation unreachable.
func waitForHTTPS(ctx context.Context, httpsPort string, expected *x509.Certificate) error {
addr := net.JoinHostPort("127.0.0.1", httpsPort)
func waitForHTTPS(
ctx context.Context, bind panel.BindAddress, httpsPort string, expected *x509.Certificate,
) error {
// The addresses are worked out once: a domain in HTTP_HOST costs a lookup,
// and inside the retry loop that would eat the whole verification budget.
addrs := bind.ProbeAddrs(httpsPort)

err := waitFor(ctx, verifyInterval, func() error {
return probeHTTPS(ctx, addr, expected)
return panel.ProbeEach(addrs, func(addr string) error {
return probeCertificate(ctx, addr, expected)
})
})
if err != nil {
return errors.WithMessagef(err, "the panel is not serving HTTPS on port %s", httpsPort)
return errors.WithMessagef(err, "the panel is not serving HTTPS on %s", strings.Join(addrs, ", "))
}

return nil
}

func probeHTTPS(ctx context.Context, addr string, expected *x509.Certificate) error {
func probeCertificate(ctx context.Context, addr string, expected *x509.Certificate) error {
result, err := tlsprobe.Leaf(ctx, addr, probeTimeout)
if err != nil {
return err
Expand All @@ -306,7 +328,7 @@ func probeHTTPS(ctx context.Context, addr string, expected *x509.Certificate) er
}

if !bytes.Equal(result.Leaf.Raw, expected.Raw) {
return errors.New("the panel is still serving another certificate")
return errors.Errorf("%s is serving another certificate", addr)
}

return nil
Expand All @@ -332,9 +354,15 @@ func rollback(ctx context.Context, paths gameap.PanelPaths, lines []string, caus
return errors.WithMessage(cause, "HTTPS is not enabled, the previous configuration is restored")
}

func reportEnabled(cliCtx *cli.Context, values map[string]string, setup certificateSetup, httpsPort string) {
func reportEnabled(
cliCtx *cli.Context,
values map[string]string,
bind panel.BindAddress,
setup certificateSetup,
httpsPort string,
) {
log.Println("HTTPS is enabled.")
log.Println(" URL: ", panelURL(values, httpsPort))
log.Println(" URL: ", panelURL(values, bind, httpsPort))
log.Println(" Certificate:", setup.certPath)
log.Println(" Private key:", setup.keyPath)
log.Println(" Names: ", certificateNames(setup.leaf))
Expand Down Expand Up @@ -362,11 +390,11 @@ func reportEnabled(cliCtx *cli.Context, values map[string]string, setup certific
log.Printf("HTTP is still served on port %s. Pass --force-https to redirect it.\n", httpPort)
}

func panelURL(values map[string]string, httpsPort string) string {
host := panel.ConfigValue(values, panel.HTTPHostKey)
if host == "" || host == wildcardHost {
host = localhostName
}
// panelURL names the panel where a browser can open it: what HTTP_HOST says
// when it names an address, then the one the listeners are pinned to, which
// HTTP_BIND_IP can set on its own. Loopback is left when neither names one.
func panelURL(values map[string]string, bind panel.BindAddress, httpsPort string) string {
host := firstNamedHost(panel.ConfigValue(values, panel.HTTPHostKey), bind.IP)

if httpsPort == panel.DefaultHTTPSPort {
return "https://" + urlHost(host)
Expand All @@ -375,6 +403,24 @@ func panelURL(values map[string]string, httpsPort string) string {
return "https://" + net.JoinHostPort(host, httpsPort)
}

// firstNamedHost returns the first candidate that names a host to connect to,
// which the wildcards and the empty string do not.
func firstNamedHost(candidates ...string) string {
for _, host := range candidates {
if host == "" {
continue
}

if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() {
continue
}

return host
}

return localhostName
}

// urlHost brackets an IPv6 literal, which a URL needs even where there is no
// port to separate it from. net.JoinHostPort does it for every other case.
func urlHost(host string) string {
Expand Down
Loading
Loading