Bläddra i källkod

replace stunnel with nginx for SSL termination

nginx terminates TLS on 80/443 and routes POST / to the kerberos
listener and everything else to the web api, plus 5193 to the BOS
SSL listener over stream. it links a custom OpenSSL 1.0.2u so AIM
6.2-7.x v2-hello handshakes still work.
Mike 2 veckor sedan
förälder
incheckning
f0fd53f1b8

+ 2 - 1
.gitignore

@@ -5,4 +5,5 @@ dist/
 *.DS_Store
 *.DS_Store
 certs/
 certs/
 .vscode/
 .vscode/
-.cursor/
+.cursor/
+clients/

+ 4 - 4
AGENTS.md

@@ -15,7 +15,7 @@ AOL/Yahoo and non-commercial.
 | Test                | `go test -race ./...`                                      |
 | Test                | `go test -race ./...`                                      |
 | Lint (matches CI)   | `gofmt -s -l . && go vet ./...`                            |
 | Lint (matches CI)   | `gofmt -s -l . && go vet ./...`                            |
 | Run (dev, plain)    | `make run`                                                 |
 | Run (dev, plain)    | `make run`                                                 |
-| Run (dev, SSL)      | `make run-ssl` (+ `make run-stunnel` in a second terminal) |
+| Run (dev, SSL)      | `make run-ssl` (+ `make run-nginx` in a second terminal)   |
 | Generate config     | `make config`                                              |
 | Generate config     | `make config`                                              |
 | Regenerate mocks    | `mockery`                                                  |
 | Regenerate mocks    | `mockery`                                                  |
 | Build Docker images | `make docker-images`                                       |
 | Build Docker images | `make docker-images`                                       |
@@ -26,11 +26,11 @@ The binary in `cmd/server` starts five servers concurrently via `errgroup`:
 
 
 | Server   | Protocol                   | Default port                        |
 | Server   | Protocol                   | Default port                        |
 |----------|----------------------------|-------------------------------------|
 |----------|----------------------------|-------------------------------------|
-| OSCAR    | FLAP/BOS (binary)          | 5190 (5193 via stunnel for SSL)     |
+| OSCAR    | FLAP/BOS (binary)          | 5190, 5191 (5193 via nginx for SSL) |
 | TOC      | TOC (text-based)           | 9898                                |
 | TOC      | TOC (text-based)           | 9898                                |
-| Kerberos | Kerberos auth              | 1088                                |
+| Kerberos | Kerberos auth              | 1088 (`POST /` via nginx on 80/443) |
 | MgmtAPI  | HTTP (management)          | 8080                                |
 | MgmtAPI  | HTTP (management)          | 8080                                |
-| WebAPI   | HTTP (web AIM-style, AMF3) | 9000 (opt-in via `ENABLE_WEBAPI=1`) |
+| WebAPI   | HTTP (web AIM-style, AMF3) | 8081 (opt-in via `ENABLE_WEBAPI`)   |
 
 
 All five servers share a common dependency container (`Container` in
 All five servers share a common dependency container (`Container` in
 `cmd/server/factory.go`) that wires together config, persistence, and business
 `cmd/server/factory.go`) that wires together config, persistence, and business

+ 91 - 0
Dockerfile.nginx

@@ -0,0 +1,91 @@
+###############################################################################
+# Build stage – compile OpenSSL 1.0.2u and nginx 1.28.0
+#
+# Why the ancient OpenSSL? AIM 6.2–7.0 begin the TLS handshake with an
+# SSLv2-format ("v2 hello") ClientHello for backward compatibility, even when
+# they go on to negotiate TLS 1.0. OpenSSL 1.1.0 removed the code that parses
+# these SSLv2-style ClientHello records, so any modern OpenSSL (1.1.x / 3.x)
+# rejects the handshake outright. OpenSSL 1.0.2u is the last release that still
+# accepts the v2 hello, so nginx must be linked against it to front these
+# clients.
+###############################################################################
+FROM debian:12.11-slim AS build
+
+ARG OPENSSL_VERSION=1.0.2u
+ARG OPENSSL_TAG=OpenSSL_1_0_2u
+ARG NGINX_VERSION=1.28.0
+
+ARG OPENSSL_URL=https://github.com/openssl/openssl/releases/download/${OPENSSL_TAG}/openssl-${OPENSSL_VERSION}.tar.gz
+ARG NGINX_URL=https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz
+
+# Build prerequisites
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends \
+        build-essential \
+        ca-certificates \
+        wget \
+        perl \
+        openssl \
+        libpcre2-dev \
+        zlib1g-dev && \
+    rm -rf /var/lib/apt/lists/*
+
+WORKDIR /usr/src
+
+# ---------- OpenSSL ----------------------------------------------------------
+# Sources only. nginx's --with-openssl builds and statically links them itself,
+# so there is no shared library to ship or point LD_LIBRARY_PATH at.
+RUN wget -qO openssl.tar.gz "${OPENSSL_URL}" && \
+    tar xzf openssl.tar.gz
+
+# ---------- nginx ------------------------------------------------------------
+# http serves the Kerberos and Web API backends, which are routed by path;
+# stream carries the OSCAR BOS listener as opaque TCP.
+RUN wget -qO nginx.tar.gz "${NGINX_URL}" && \
+    tar xzf nginx.tar.gz && \
+    cd nginx-${NGINX_VERSION} && \
+    ./configure \
+        --prefix=/etc/nginx \
+        --sbin-path=/usr/local/sbin/nginx \
+        --conf-path=/etc/nginx/nginx.conf \
+        --pid-path=/var/run/nginx.pid \
+        --lock-path=/var/run/nginx.lock \
+        --error-log-path=/dev/stdout \
+        --http-log-path=/dev/stdout \
+        --http-client-body-temp-path=/var/cache/nginx/client_temp \
+        --http-proxy-temp-path=/var/cache/nginx/proxy_temp \
+        --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
+        --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
+        --http-scgi-temp-path=/var/cache/nginx/scgi_temp \
+        --user=nobody \
+        --group=nogroup \
+        --with-http_ssl_module \
+        --with-stream \
+        --with-stream_ssl_module \
+        --with-openssl=/usr/src/openssl-${OPENSSL_VERSION} && \
+    make -j"$(nproc)" && \
+    make install
+
+# DH parameters. nginx offers no DHE ciphers at all unless ssl_dhparam is set.
+RUN openssl dhparam -out /etc/nginx/dhparam.pem 2048
+
+###############################################################################
+# Runtime stage – only what we need to run nginx
+###############################################################################
+FROM debian:12.11-slim AS runtime
+
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends libpcre2-8-0 zlib1g && \
+    rm -rf /var/lib/apt/lists/*
+
+COPY --from=build /usr/local/sbin/nginx    /usr/local/sbin/nginx
+COPY --from=build /etc/nginx/dhparam.pem   /etc/nginx/dhparam.pem
+COPY --from=build /etc/nginx/mime.types    /etc/nginx/mime.types
+
+# Mount points for the user-supplied nginx.conf, server.pem and web client, plus the temp dirs the worker writes buffered bodies to.
+RUN mkdir -p /etc/nginx/certs /srv/client /var/run /var/cache/nginx && \
+    chown nobody:nogroup /var/cache/nginx
+
+EXPOSE 80 443 5193
+
+ENTRYPOINT ["/usr/local/sbin/nginx", "-g", "daemon off;"]

+ 0 - 74
Dockerfile.stunnel

@@ -1,74 +0,0 @@
-###############################################################################
-# Build stage – compile OpenSSL 1.0.2u and stunnel 5.75
-#
-# Why the ancient OpenSSL? AIM 6.2–7.0 begin the TLS handshake with an
-# SSLv2-format ("v2 hello") ClientHello for backward compatibility, even when
-# they go on to negotiate TLS 1.0. OpenSSL 1.1.0 removed the code that parses
-# these SSLv2-style ClientHello records, so any modern OpenSSL (1.1.x / 3.x)
-# rejects the handshake outright. OpenSSL 1.0.2u is the last release that still
-# accepts the v2 hello, so stunnel must be linked against it to front these
-# clients.
-###############################################################################
-FROM debian:12.11-slim AS build
-
-ARG OPENSSL_VERSION=1.0.2u
-ARG OPENSSL_TAG=OpenSSL_1_0_2u
-ARG STUNNEL_VERSION=5.76
-
-ARG OPENSSL_URL=https://github.com/openssl/openssl/releases/download/${OPENSSL_TAG}/openssl-${OPENSSL_VERSION}.tar.gz
-ARG STUNNEL_URL=https://www.stunnel.org/archive/5.x/stunnel-${STUNNEL_VERSION}.tar.gz
-
-# Build prerequisites
-RUN apt-get update && \
-    apt-get install -y --no-install-recommends \
-        build-essential \
-        ca-certificates \
-        wget \
-        perl \
-        zlib1g-dev \
-        pkg-config && \
-    rm -rf /var/lib/apt/lists/*
-
-WORKDIR /usr/src
-
-# ---------- OpenSSL ----------------------------------------------------------
-RUN wget -qO openssl.tar.gz  "${OPENSSL_URL}" && \
-    tar xzf openssl.tar.gz && \
-    cd openssl-${OPENSSL_VERSION} && \
-    ./config --prefix=/usr/local/openssl --openssldir=/usr/local/openssl shared zlib && \
-    make -j"$(nproc)" && \
-    make install_sw
-
-# ---------- stunnel ----------------------------------------------------------
-RUN wget -qO stunnel.tar.gz "${STUNNEL_URL}" && \
-    tar xzf stunnel.tar.gz && \
-    cd stunnel-${STUNNEL_VERSION} && \
-    ./configure \
-        --with-ssl=/usr/local/openssl \
-        --prefix=/usr/local \
-        --sysconfdir=/etc \
-        --disable-libwrap && \
-    make -j"$(nproc)" && \
-    make install
-
-###############################################################################
-# Runtime stage – only what we need to run stunnel
-###############################################################################
-FROM debian:bookworm-slim AS runtime
-
-COPY --from=build /usr/local/openssl /usr/local/openssl
-COPY --from=build /usr/local/bin/stunnel   /usr/local/bin/
-COPY --from=build /usr/local/lib           /usr/local/lib
-
-# Make sure the custom OpenSSL is preferred at runtime
-ENV LD_LIBRARY_PATH="/usr/local/openssl/lib"
-
-# Directory to hold the user‑supplied stunnel.conf
-RUN mkdir -p /etc/stunnel
-
-WORKDIR /etc/stunnel
-EXPOSE 443 1088
-
-ENTRYPOINT ["stunnel"]
-# You can pass the config file name as CMD or at `docker run` time, e.g.:
-# CMD ["stunnel.conf"]

+ 15 - 10
Makefile

@@ -25,6 +25,11 @@ DOCKER_RUN_GO_RELEASER := @docker run \
 	--workdir /go/src/open-oscar-server \
 	--workdir /go/src/open-oscar-server \
 	$(DOCKER_IMAGE_TAG_GO_RELEASER)
 	$(DOCKER_IMAGE_TAG_GO_RELEASER)
 OSCAR_HOST ?= ras.dev
 OSCAR_HOST ?= ras.dev
+# Tag of the SSL terminator image. docker-compose.yaml defaults to the same
+# value, so keep the two in sync when bumping nginx.
+NGINX_IMAGE ?= ras-nginx:1.28.0-openssl-1.0.2u
+# Host directory holding the web client that nginx serves.
+CLIENT_DIR ?= ./clients
 
 
 .PHONY: config-basic config-ssl config
 .PHONY: config-basic config-ssl config
 config-basic: ## Generate basic config file template
 config-basic: ## Generate basic config file template
@@ -97,28 +102,28 @@ release-sign: ## Full GoReleaser on host with Windows signing (needs $(GORELEASE
 docker-image-ras: ## Build Open OSCAR Server image
 docker-image-ras: ## Build Open OSCAR Server image
 	docker build -t ras:latest -f Dockerfile .
 	docker build -t ras:latest -f Dockerfile .
 
 
-.PHONY: docker-image-stunnel
-docker-image-stunnel: ## Build stunnel image pinned to v5.75 / OpenSSL 1.0.2u
-	docker build -t ras-stunnel:5.75-openssl-1.0.2u -f Dockerfile.stunnel .
+.PHONY: docker-image-nginx
+docker-image-nginx: ## Build nginx image pinned to v1.28.0 / OpenSSL 1.0.2u
+	docker build -t $(NGINX_IMAGE) -f Dockerfile.nginx .
 
 
 .PHONY: docker-image-certgen
 .PHONY: docker-image-certgen
 docker-image-certgen: ## Build minimal helper image with openssl & nss tools
 docker-image-certgen: ## Build minimal helper image with openssl & nss tools
 	docker build -t ras-certgen:latest -f Dockerfile.certgen .
 	docker build -t ras-certgen:latest -f Dockerfile.certgen .
 
 
 .PHONY: docker-images
 .PHONY: docker-images
-docker-images: docker-image-ras docker-image-stunnel docker-image-certgen
+docker-images: docker-image-ras docker-image-nginx docker-image-certgen
 
 
 .PHONY: docker-run
 .PHONY: docker-run
 docker-run:
 docker-run:
-	OSCAR_HOST=$(OSCAR_HOST) docker compose up open-oscar-server stunnel
+	OSCAR_HOST=$(OSCAR_HOST) NGINX_IMAGE=$(NGINX_IMAGE) CLIENT_DIR=$(CLIENT_DIR) docker compose up open-oscar-server nginx
 
 
 .PHONY: docker-run-bg
 .PHONY: docker-run-bg
 docker-run-bg: ## Run Open OSCAR Server in background with docker-compose
 docker-run-bg: ## Run Open OSCAR Server in background with docker-compose
-	OSCAR_HOST=$(OSCAR_HOST) docker compose up -d open-oscar-server stunnel
+	OSCAR_HOST=$(OSCAR_HOST) NGINX_IMAGE=$(NGINX_IMAGE) CLIENT_DIR=$(CLIENT_DIR) docker compose up -d open-oscar-server nginx
 
 
 .PHONY: docker-run-stop
 .PHONY: docker-run-stop
 docker-run-stop: ## Stop Open OSCAR Server docker-compose services
 docker-run-stop: ## Stop Open OSCAR Server docker-compose services
-	OSCAR_HOST=$(OSCAR_HOST) docker compose down
+	OSCAR_HOST=$(OSCAR_HOST) NGINX_IMAGE=$(NGINX_IMAGE) CLIENT_DIR=$(CLIENT_DIR) docker compose down
 
 
 .PHONY: run
 .PHONY: run
 run: # run the server with plain socket config
 run: # run the server with plain socket config
@@ -128,9 +133,9 @@ run: # run the server with plain socket config
 run-ssl: # run the server with ssl socket config
 run-ssl: # run the server with ssl socket config
 	./scripts/run_dev.sh ./config/ssl/settings.env
 	./scripts/run_dev.sh ./config/ssl/settings.env
 
 
-.PHONY: run-stunnel
-run-stunnel: # run stunnel for SSL termination
-	./scripts/run_stunnel.sh ./certs/server.pem
+.PHONY: run-nginx
+run-nginx: # run nginx for SSL termination
+	NGINX_IMAGE=$(NGINX_IMAGE) CLIENT_DIR=$(CLIENT_DIR) ./scripts/run_nginx.sh ./certs/server.pem
 
 
 ################################################################################
 ################################################################################
 # SSL Helpers
 # SSL Helpers

+ 1 - 1
config/config.go

@@ -96,7 +96,7 @@ func (e Endpoint) AdvertisedHost() string {
 type Config struct {
 type Config struct {
 	BOSListeners            []string `envconfig:"OSCAR_LISTENERS" required:"true" basic:"LOCAL://0.0.0.0:5190" ssl:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
 	BOSListeners            []string `envconfig:"OSCAR_LISTENERS" required:"true" basic:"LOCAL://0.0.0.0:5190" ssl:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
 	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://ras.dev:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
 	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://ras.dev:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
-	BOSListenersSSL         []string `envconfig:"OSCAR_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:5191" description:"Network listeners for core OSCAR services that receive decrypted traffic from an SSL terminator such as stunnel. Clients that connect through these listeners are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL, keeping them on the SSL path for the rest of the session.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Each listener needs a listener in OSCAR_LISTENERS and OSCAR_ADVERTISED_LISTENERS_SSL\n\t- A listener without a matching OSCAR_ADVERTISED_LISTENERS_SSL entry is not started\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5191\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5191,LAN://192.168.1.10:5192"`
+	BOSListenersSSL         []string `envconfig:"OSCAR_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:5191" description:"Network listeners for core OSCAR services that receive decrypted traffic from an SSL terminator such as nginx. Clients that connect through these listeners are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL, keeping them on the SSL path for the rest of the session.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Each listener needs a listener in OSCAR_LISTENERS and OSCAR_ADVERTISED_LISTENERS_SSL\n\t- A listener without a matching OSCAR_ADVERTISED_LISTENERS_SSL entry is not started\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5191\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5191,LAN://192.168.1.10:5192"`
 	BOSAdvertisedHostsSSL   []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://ras.dev:5193" description:"Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the server that terminates SSL. Each listener defined here must have a matching listener in OSCAR_LISTENERS_SSL for the terminator to forward decrypted traffic to."`
 	BOSAdvertisedHostsSSL   []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_SSL" required:"false" basic:"" ssl:"LOCAL://ras.dev:5193" description:"Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the server that terminates SSL. Each listener defined here must have a matching listener in OSCAR_LISTENERS_SSL for the terminator to forward decrypted traffic to."`
 	KerberosListeners       []string `envconfig:"KERBEROS_LISTENERS" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:1088" description:"Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:1088\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:1088,LAN://192.168.1.10:1087"`
 	KerberosListeners       []string `envconfig:"KERBEROS_LISTENERS" required:"false" basic:"" ssl:"LOCAL://0.0.0.0:1088" description:"Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:1088\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:1088,LAN://192.168.1.10:1087"`
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`

+ 123 - 0
config/ssl/nginx.conf

@@ -0,0 +1,123 @@
+worker_processes  auto;
+error_log         /dev/stdout info;
+pid               /var/run/nginx.pid;
+
+events {
+    worker_connections 1024;
+}
+
+http {
+    include      /etc/nginx/mime.types;
+    default_type application/octet-stream;
+
+    log_format http '$time_iso8601 $remote_addr "$request" $status -> $upstream_addr '
+                    'sent=$body_bytes_sent ssl=$ssl_protocol/$ssl_cipher '
+                    'dur=$request_time';
+    access_log /dev/stdout http;
+
+    # POST / is the Kerberos auth endpoint; every other request that isn't
+    # static content belongs to the Web API.
+    map "$request_method $uri" $backend {
+        default  webapi;
+        "POST /" kerberos;
+    }
+
+    upstream kerberos {
+        server open-oscar-server:1088;
+    }
+
+    upstream webapi {
+        server open-oscar-server:8081;
+        # The Flash client sends a request per keystroke burst and polls every
+        # 500ms. Without a pool every one of those opens a new connection to the
+        # server, and a connect that stalls costs the client its event loop.
+        keepalive 32;
+    }
+
+    server {
+        listen 80;
+        listen 443 ssl;
+
+        ssl_protocols             SSLv3 TLSv1 TLSv1.1 TLSv1.2;
+        # allow legacy ciphers required by 2000s-era AIM clients
+        ssl_ciphers               ALL:!aNULL;
+        ssl_prefer_server_ciphers off;
+
+        ssl_certificate           /etc/nginx/certs/server.pem;
+        ssl_certificate_key       /etc/nginx/certs/server.pem;
+        ssl_dhparam               /etc/nginx/dhparam.pem;
+
+        ssl_session_cache         shared:HTTPSSL:10m;
+        ssl_session_timeout       5m;
+        ssl_session_tickets       off;
+
+        client_max_body_size      10m;
+        # relative Location headers, so redirects keep the requested port.
+        absolute_redirect         off;
+        # required for the upstream keepalive pool
+        proxy_http_version        1.1;
+        proxy_set_header          Connection "";
+        proxy_set_header          Host $http_host;
+        proxy_set_header          X-Real-IP $remote_addr;
+        proxy_set_header          X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header          X-Forwarded-Proto $scheme;
+        proxy_connect_timeout     10s;
+        # /aim/fetchEvents long-polls for up to 60 seconds.
+        proxy_read_timeout        120s;
+        proxy_send_timeout        120s;
+
+        # load static web content for AIM web clients
+        location = /client {
+            return 301 /client/;
+        }
+
+        location ^~ /client/ {
+            root            /srv;
+            index           index.html;
+            # js and css are the only assets big enough to be worth
+            # compressing; the images already are.
+            gzip            on;
+            gzip_types      text/css application/javascript;
+            gzip_min_length 1024;
+            gzip_vary       on;
+        }
+
+        # route remaining traffic to the backend selected by $backend
+        location / {
+            proxy_pass http://$backend;
+        }
+    }
+}
+
+stream {
+    log_format proxy '$remote_addr -> $upstream_addr $protocol $status '
+                     'ssl=$ssl_protocol/$ssl_cipher sent=$bytes_sent '
+                     'recv=$bytes_received dur=$session_time';
+    access_log /dev/stdout proxy;
+
+    ssl_protocols             SSLv3 TLSv1 TLSv1.1 TLSv1.2;
+    ssl_ciphers               ALL:!aNULL;
+    ssl_prefer_server_ciphers off;
+
+    ssl_certificate           /etc/nginx/certs/server.pem;
+    ssl_certificate_key       /etc/nginx/certs/server.pem;
+    ssl_dhparam               /etc/nginx/dhparam.pem;
+
+    ssl_session_cache         shared:STREAMSSL:10m;
+    ssl_session_timeout       5m;
+    ssl_session_tickets       off;
+
+    proxy_connect_timeout     10s;
+    # a client going away ends the whole session rather than leaving the
+    # upstream half open.
+    proxy_half_close          off;
+    # clients send FLAP keep-alive frames every couple of minutes, so a
+    # connection quiet for this long is dead.
+    proxy_timeout             10m;
+
+    # BOS SSL listener
+    server {
+        listen     5193 ssl;
+        proxy_pass open-oscar-server:5191;
+    }
+}

+ 3 - 3
config/ssl/settings.env

@@ -34,9 +34,9 @@ export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190
 export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://ras.dev:5190
 export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://ras.dev:5190
 
 
 # Network listeners for core OSCAR services that receive decrypted traffic from
 # Network listeners for core OSCAR services that receive decrypted traffic from
-# an SSL terminator such as stunnel. Clients that connect through these
-# listeners are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL,
-# keeping them on the SSL path for the rest of the session.
+# an SSL terminator such as nginx. Clients that connect through these listeners
+# are redirected to the hostnames in OSCAR_ADVERTISED_LISTENERS_SSL, keeping
+# them on the SSL path for the rest of the session.
 # 
 # 
 # Format:
 # Format:
 # 	- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]
 # 	- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]

+ 0 - 20
config/ssl/stunnel.conf

@@ -1,20 +0,0 @@
-foreground = yes
-debug = 7
-
-[ssl_proxy]
-options = NO_SSLv2
-options = NO_SSLv3
-options = NO_TLSv1_1
-ciphers = ALL
-accept = 443
-connect = open-oscar-server:1088
-cert = /etc/stunnel/certs/server.pem
-
-[oscar_proxy]
-options = NO_SSLv2
-options = NO_SSLv3
-options = NO_TLSv1_1
-ciphers = ALL
-accept = 5193
-connect = open-oscar-server:5191
-cert = /etc/stunnel/certs/server.pem

+ 29 - 9
docker-compose.yaml

@@ -8,13 +8,28 @@ services:
     environment:
     environment:
       - OSCAR_HOST=${OSCAR_HOST}
       - OSCAR_HOST=${OSCAR_HOST}
     command:
     command:
-      - "openssl req -x509 -newkey rsa:1024
-         -keyout key.pem
-         -out cert.pem
+      - "openssl req -x509 -newkey rsa:${CERT_KEY_BITS:-2048}
+         -keyout ca-key.pem
+         -out ca.crt
          -sha256 -days 365 -nodes
          -sha256 -days 365 -nodes
+         -subj \"/CN=Open OSCAR Server Root CA\"
+         && openssl req -newkey rsa:${CERT_KEY_BITS:-2048}
+         -keyout key.pem
+         -out server.csr
+         -sha256 -nodes
          -subj \"/CN=${OSCAR_HOST}\"
          -subj \"/CN=${OSCAR_HOST}\"
+         && echo \"basicConstraints=critical,CA:FALSE\" > server.ext
+         && echo \"keyUsage=critical,digitalSignature,keyEncipherment\" >> server.ext
+         && echo \"extendedKeyUsage=serverAuth\" >> server.ext
+         && echo \"subjectAltName=DNS:${OSCAR_HOST},DNS:api.oscar.aol.com,DNS:api.screenname.aol.com,DNS:login.oscar.aol.com,DNS:my.screenname.aol.com\" >> server.ext
+         && openssl x509 -req
+         -in server.csr
+         -CA ca.crt -CAkey ca-key.pem -CAcreateserial
+         -out cert.pem
+         -sha256 -days 365
+         -extfile server.ext
          && cat cert.pem key.pem > server.pem
          && cat cert.pem key.pem > server.pem
-         && rm cert.pem key.pem"
+         && rm cert.pem key.pem ca-key.pem server.csr server.ext ca.srl"
 
 
   nss-gen:
   nss-gen:
     image: ras-certgen:latest
     image: ras-certgen:latest
@@ -42,12 +57,17 @@ services:
       - .:/project
       - .:/project
     working_dir: /project
     working_dir: /project
 
 
-  stunnel:
-    image: ras-stunnel:5.75-openssl-1.0.2u
+  nginx:
+    image: ${NGINX_IMAGE:-ras-nginx:1.28.0-openssl-1.0.2u}
+    # nginx resolves the upstream hostname at startup, so the server container
+    # must exist first.
+    depends_on:
+      - open-oscar-server
     ports:
     ports:
+      - "80:80"
       - "443:443"
       - "443:443"
       - "5193:5193"
       - "5193:5193"
     volumes:
     volumes:
-      - ./config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro
-      - ./certs:/etc/stunnel/certs:ro
-    command: stunnel.conf
+      - ./config/ssl/nginx.conf:/etc/nginx/nginx.conf:ro
+      - ./certs:/etc/nginx/certs:ro
+      - ${CLIENT_DIR:-./clients}:/srv/client:ro

+ 7 - 7
docs/BUILD.md

@@ -61,12 +61,12 @@ make run
 ### SSL Socket Config (for AIM v6.2-v7.0)
 ### SSL Socket Config (for AIM v6.2-v7.0)
 
 
 To run AIM v6.2-v7.0, you must run the server with SSL enabled. This project provides tooling for generating a
 To run AIM v6.2-v7.0, you must run the server with SSL enabled. This project provides tooling for generating a
-self-signed certificate and fronting the server with the SSL proxy [stunnel](https://www.stunnel.org/downloads.html).
+self-signed certificate and fronting the server with the SSL proxy [nginx](https://nginx.org/).
 
 
-> **Note:** The stunnel image is pinned to OpenSSL 1.0.2u on purpose. These AIM clients begin the TLS handshake with an
+> **Note:** The nginx image is pinned to OpenSSL 1.0.2u on purpose. These AIM clients begin the TLS handshake with an
 > SSLv2-format ("v2 hello") ClientHello for backward compatibility, even when they go on to negotiate TLS 1.0. OpenSSL
 > SSLv2-format ("v2 hello") ClientHello for backward compatibility, even when they go on to negotiate TLS 1.0. OpenSSL
 > 1.1.0 removed support for parsing this v2 hello, so modern OpenSSL (1.1.x / 3.x) rejects the handshake outright.
 > 1.1.0 removed support for parsing this v2 hello, so modern OpenSSL (1.1.x / 3.x) rejects the handshake outright.
-> 1.0.2u is the last release that still accepts it. This is why `Dockerfile.stunnel` builds its own OpenSSL instead of
+> 1.0.2u is the last release that still accepts it. This is why `Dockerfile.nginx` builds its own OpenSSL instead of
 > using the distro package.
 > using the distro package.
 
 
 #### Prerequisites
 #### Prerequisites
@@ -87,7 +87,7 @@ cd open-oscar-server
 This builds Docker images for:
 This builds Docker images for:
 
 
 - Certificate generation
 - Certificate generation
-- SSL termination
+- SSL termination (nginx)
 - The Open OSCAR Server runtime
 - The Open OSCAR Server runtime
 
 
 ```bash
 ```bash
@@ -121,12 +121,12 @@ Start the server in a terminal.
 make run-ssl
 make run-ssl
 ```
 ```
 
 
-#### 6. Start stunnel
+#### 6. Start nginx
 
 
-In a separate terminal, start stunnel.
+In a separate terminal, start nginx.
 
 
 ```bash
 ```bash
-make run-stunnel
+make run-nginx
 ```
 ```
 
 
 #### 7. Client Configuration
 #### 7. Client Configuration

+ 1 - 1
docs/DOCKER.md

@@ -22,7 +22,7 @@ cd open-oscar-server
 This builds Docker images for:
 This builds Docker images for:
 
 
 - Certificate generation
 - Certificate generation
-- SSL termination
+- SSL termination and HTTP routing (nginx, ports 80/443/5193)
 - The Open OSCAR Server runtime
 - The Open OSCAR Server runtime
 
 
 ```bash
 ```bash

+ 34 - 0
scripts/run_nginx.sh

@@ -0,0 +1,34 @@
+#!/bin/sh
+
+set -e
+
+if [ -z "$1" ]; then
+  echo "Usage: [CLIENT_DIR=/path/to/web/client] $0 /path/to/server.pem"
+  exit 1
+fi
+
+PEM_PATH="$1"
+NGINX_IMAGE="${NGINX_IMAGE:-ras-nginx:1.28.0-openssl-1.0.2u}"
+
+if [ ! -f "$PEM_PATH" ]; then
+  echo "$PEM_PATH is not a file. Run 'make docker-cert' to generate one."
+  exit 1
+fi
+
+set -- --rm -it \
+  --add-host=host.docker.internal:host-gateway \
+  --add-host=open-oscar-server:host-gateway \
+  -v "$PEM_PATH:/etc/nginx/certs/server.pem:ro" \
+  -v "$(pwd)/config/ssl/nginx.conf:/etc/nginx/nginx.conf:ro" \
+  -p 80:80 \
+  -p 443:443 \
+  -p 5193:5193
+
+# Subdirectories of CLIENT_DIR are served under /client, e.g. the directory
+# aim-client is served at /client/aim-client/.
+if [ -n "${CLIENT_DIR:-}" ]; then
+  mkdir -p "$CLIENT_DIR"
+  set -- "$@" -v "$CLIENT_DIR:/srv/client:ro"
+fi
+
+docker run "$@" "$NGINX_IMAGE"

+ 0 - 19
scripts/run_stunnel.sh

@@ -1,19 +0,0 @@
-#!/bin/sh
-
-set -e
-
-if [ -z "$1" ]; then
-  echo "Usage: $0 /path/to/server.pem"
-  exit 1
-fi
-
-PEM_PATH="$1"
-
-docker run --rm -it \
-  --add-host=host.docker.internal:host-gateway \
-  --add-host=open-oscar-server:host-gateway \
-  -v "$PEM_PATH:/etc/stunnel/certs/server.pem:ro" \
-  -v "$(pwd)/config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro" \
-  -p 443:443 \
-  -p 5193:5193 \
-  ras-stunnel:5.75-openssl-1.0.2u stunnel.conf

+ 1 - 5
server/webapi/handlers/login_psp.go

@@ -171,11 +171,7 @@ func clearBOSTokenCookie(w http.ResponseWriter) {
 }
 }
 
 
 func defaultLoginSuccURL(r *http.Request) string {
 func defaultLoginSuccURL(r *http.Request) string {
-	scheme := "http"
-	if r.TLS != nil {
-		scheme = "https"
-	}
-	return scheme + "://" + r.Host + "/"
+	return requestScheme(r) + "://" + r.Host + "/"
 }
 }
 
 
 func safeLoginRedirectURL(r *http.Request, succURL string) string {
 func safeLoginRedirectURL(r *http.Request, succURL string) string {

+ 9 - 0
server/webapi/handlers/login_psp_test.go

@@ -169,6 +169,15 @@ func TestAuthHandler_LoginPSP_POST_InvalidCredentials(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "Invalid screen name or password")
 	assert.Contains(t, rr.Body.String(), "Invalid screen name or password")
 }
 }
 
 
+func TestDefaultLoginSuccURL(t *testing.T) {
+	req := httptest.NewRequest(http.MethodGet, "http://ras.dev/_cqr/login/login.psp", nil)
+	assert.Equal(t, "http://ras.dev/", defaultLoginSuccURL(req))
+
+	// TLS terminated upstream, so the scheme only survives in the header.
+	req.Header.Set("X-Forwarded-Proto", "https")
+	assert.Equal(t, "https://ras.dev/", defaultLoginSuccURL(req))
+}
+
 func TestSafeLoginRedirectURL(t *testing.T) {
 func TestSafeLoginRedirectURL(t *testing.T) {
 	req := httptest.NewRequest(http.MethodGet, "http://localhost/_cqr/login/login.psp", nil)
 	req := httptest.NewRequest(http.MethodGet, "http://localhost/_cqr/login/login.psp", nil)