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
16 changes: 12 additions & 4 deletions gateway/Caddyfile.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,20 @@ http://healthcheck.internal {
}
{{- end }}

@address vars_regexp {http.request.host} (?i)^[0-9a-f]{32}\.

# The path belongs to the device's own HTTP server and is not ours to
# interpret. Caddy forwards it as the client wrote it, escaping intact.
reverse_proxy {{ $cfg.APIBackend }} {
header_up Host {host}
header_up X-Request-ID {http.request.uuid}
header_up X-Real-IP {remote_host}
handle @address {
reverse_proxy {{ $cfg.APIBackend }} {
header_up Host {host}
header_up X-Request-ID {http.request.uuid}
header_up X-Real-IP {remote_host}
}
}

handle {
respond 404
}
}
{{- end }}
Expand Down
2 changes: 2 additions & 0 deletions openapi/spec/paths/api@namespaces@{tenant}.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ delete:
description: Success to delete a namespace.
'401':
$ref: ../components/responses/401.yaml
'402':
$ref: ../components/responses/402.yaml
'403':
$ref: ../components/responses/403.yaml
'404':
Expand Down
4 changes: 4 additions & 0 deletions pkg/webendpoints/webendpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ func Domain(preferred, fallback string) string {
return fallback
}

// ProxyPath is the server route a request addressed to a web endpoint is rewritten to before it
// is carried into the device. The response is the device's own, so no API schema describes it.
const ProxyPath = "/http/proxy"

var address = regexp.MustCompile(`^[a-f0-9]{32}$`)

// AddressFromHost is the inverse of Host: it takes the Host of an inbound
Expand Down
2 changes: 2 additions & 0 deletions server/app/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/gorilla/websocket"
"github.com/labstack/echo/v5"
"github.com/shellhub-io/shellhub/pkg/webendpoints"
"github.com/shellhub-io/shellhub/server/api/routes"
"github.com/shellhub-io/shellhub/server/api/routes/middleware"
sshhttp "github.com/shellhub-io/shellhub/server/ssh/http"
Expand Down Expand Up @@ -122,6 +123,7 @@ func TestOpenAPIValidationSkipper(t *testing.T) {
{routes.InternalMetricsURL, true},
{"/mcp", true},
{"/mcp/session", true},
{webendpoints.ProxyPath, true},
{web.WebSessionRoute, false},
{"/api/devices", false},
{"/api/namespaces", false},
Expand Down
4 changes: 3 additions & 1 deletion server/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/shellhub-io/shellhub/pkg/api/query"
"github.com/shellhub-io/shellhub/pkg/cache"
"github.com/shellhub-io/shellhub/pkg/envs"
"github.com/shellhub-io/shellhub/pkg/webendpoints"
"github.com/shellhub-io/shellhub/pkg/worker"
"github.com/shellhub-io/shellhub/pkg/worker/asynq"
"github.com/shellhub-io/shellhub/server/api/routes"
Expand Down Expand Up @@ -447,7 +448,8 @@ func openAPIValidationSkipper(ctx *echo.Context) bool {
case sshhttp.HandleConnectionV1Path,
sshhttp.HandleConnectionV2Path,
sshhttp.HandleRevdialPath,
web.WebsocketSSHBridgeRoute:
web.WebsocketSSHBridgeRoute,
webendpoints.ProxyPath:
return true
}

Expand Down
13 changes: 13 additions & 0 deletions tests/cmd/device-http/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ARG GOLANG_VERSION

FROM golang:${GOLANG_VERSION}

WORKDIR /src

COPY main.go .

RUN CGO_ENABLED=0 go build -o /usr/local/bin/device-http main.go

USER nobody

ENTRYPOINT ["device-http"]
90 changes: 90 additions & 0 deletions tests/cmd/device-http/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"log"
"math/big"
"net"
"net/http"
"os"
"time"
)

type seen struct {
Name string `json:"name"`
Host string `json:"host"`
URI string `json:"uri"`
TLS bool `json:"tls"`
}

func selfSigned(domain string) (tls.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}

template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: domain},
DNSNames: []string{domain},
NotBefore: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}

der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, err
}

return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
}

func main() {
name := os.Getenv("NAME")

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(seen{Name: name, Host: r.Host, URI: r.RequestURI, TLS: r.TLS != nil}); err != nil {
log.Printf("answering a request: %v", err)
}
})

certificate, err := selfSigned(os.Getenv("TLS_DOMAIN"))
if err != nil {
log.Fatal(err)
}

var listen net.ListenConfig

plainListener, err := listen.Listen(context.Background(), "tcp", os.Getenv("HTTP_ADDRESS"))
if err != nil {
log.Fatal(err)
}

tcpListener, err := listen.Listen(context.Background(), "tcp", os.Getenv("TLS_ADDRESS"))
if err != nil {
log.Fatal(err)
}

secureListener := tls.NewListener(tcpListener, &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS13,
})

plain := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
secure := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}

go func() { log.Fatal(secure.Serve(secureListener)) }()

log.Print("listening")
log.Fatal(plain.Serve(plainListener))
}
11 changes: 11 additions & 0 deletions tests/environment/configurator.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ func (dcc *DockerComposeConfigurator) WithEveryAddressIn(country string) *Docker
return dcc
}

// WithAutoSSL serves the gateway over HTTPS too, on a port reserved for it, which
// [DockerCompose.HTTPSAddress] names. The gateway then redirects a plain HTTP request to the
// instance's domain to HTTPS on port 443, which the stack does not publish, so a client of
// [DockerCompose.R] reaches no API route.
func (dcc *DockerComposeConfigurator) WithAutoSSL() *DockerComposeConfigurator {
dcc.cfg.AutoSSL = true
dcc.cfg.HTTPSPort = ReservePort(dcc.t)

return dcc
}

// WithCronTrigger publishes the stack's redis on a port reserved for it, so [DockerCompose.RunCron]
// can reach the queue the server schedules its cron jobs on. Without it redis takes whatever port
// the daemon picks, which the test neither knows nor, under rootless Docker, can reach.
Expand Down
6 changes: 6 additions & 0 deletions tests/environment/docker_compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ func (dc *DockerCompose) SSHAddress() string {
return dc.stack.SSHAddress()
}

// HTTPSAddress is the host address the gateway's HTTPS port is published on. Only a stack brought
// up with [DockerComposeConfigurator.WithAutoSSL] publishes it.
func (dc *DockerCompose) HTTPSAddress() string {
return dc.stack.HTTPSAddress()
}
Comment thread
otavio marked this conversation as resolved.

// BaseURL is the HTTP base URL the gateway is published on, for a client [DockerCompose.R] cannot
// stand in for, such as a WebSocket.
func (dc *DockerCompose) BaseURL() string {
Expand Down
10 changes: 7 additions & 3 deletions tests/environment/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ import (
// HTTPSPort, random when empty. SuppliedCertificate is the certificate the gateway serves instead of
// obtaining one. ACME starts a Pebble ACME server in the stack and points the gateway's ACME CA at
// it. Pebble validates every challenge without reaching the gateway, so SHELLHUB_DOMAIN only has to
// be a name the gateway asks a public CA for, which localhost is not.
// be a name the gateway asks a public CA for, which localhost is not. AutoSSL turns the gateway's
// automatic HTTPS on with neither, so the gateway gets its certificates on its own; the gateway then
// redirects a plain HTTP request to the instance's domain to HTTPS on port 443, which the stack
// does not publish.
type Config struct {
Edition Edition
Name string
Expand All @@ -58,6 +61,7 @@ type Config struct {
LocatedCountry string
SuppliedCertificate *Certificate
ACME bool
AutoSSL bool
}

type imageBuild struct {
Expand Down Expand Up @@ -130,7 +134,7 @@ func Up(ctx context.Context, cfg Config) (_ *Stack, err error) {
}

ports := []*string{&cfg.HTTPPort, &cfg.SSHPort}
if cfg.SuppliedCertificate != nil || cfg.ACME {
if cfg.SuppliedCertificate != nil || cfg.ACME || cfg.AutoSSL {
ports = append(ports, &cfg.HTTPSPort)
}

Expand Down Expand Up @@ -394,7 +398,7 @@ func (s *Stack) SSHPort() string { return s.envs["SHELLHUB_SSH_PORT"] }
func (s *Stack) BaseURL() string { return "http://localhost:" + s.HTTPPort() }

// HTTPSAddress returns the host:port the gateway publishes HTTPS on. Only a stack started with
// [Config.SuppliedCertificate] or [Config.ACME] publishes it.
// [Config.SuppliedCertificate], [Config.ACME] or [Config.AutoSSL] publishes it.
func (s *Stack) HTTPSAddress() string { return "localhost:" + s.envs["SHELLHUB_HTTPS_PORT"] }

// SSHAddress returns the host:port the gateway's SSH listener is reachable at.
Expand Down
2 changes: 2 additions & 0 deletions tests/environment/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ func (cfg Config) httpsLayer() (httpsLayer, error) {
layer, err = cfg.suppliedCertificateLayer()
case cfg.ACME:
layer, err = cfg.acmeLayer()
case cfg.AutoSSL:
layer = httpsLayer{envs: map[string]string{}, composeFile: "../docker-compose.autossl.yml"}
default:
return httpsLayer{}, nil
}
Expand Down
Loading
Loading