diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da37bda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +bin +output +xterm/node_modules +sshterm-server diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3105fc4..c73f9d8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,6 +5,7 @@ on: pull_request: paths: - '.github/workflows/*' + - 'Dockerfile' - '.goversion' - 'go/go.*' - '**.go' @@ -48,3 +49,8 @@ jobs: run: cd go && go test ./... - name: Run headless tests run: ./tests/run-headless-tests.sh + - name: Build test docker image + run: | + touch version.sh + docker build -t c2fmzq/sshterm-server:test . + docker run --rm --interactive c2fmzq/sshterm-server:test -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e7fe7c..d7bd8b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,11 @@ permissions: contents: read jobs: - build-all: - name: Build and create release + build-and-run-tests: + name: Build & run tests runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout # https://github.com/actions/checkout/tree/v4.1.1 @@ -19,8 +21,8 @@ jobs: id: goversion run: echo "goversion=$(<.goversion)" > "${GITHUB_OUTPUT}" - name: Set up Go - # https://github.com/actions/setup-go/tree/v5.0.0 - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 + # https://github.com/actions/setup-go/tree/v6.2.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 with: go-version: ${{steps.goversion.outputs.goversion}} - name: Build @@ -39,6 +41,33 @@ jobs: run: cd go && go test ./... - name: Run headless tests run: ./tests/run-headless-tests.sh + - name: Build test docker image + run: | + touch version.sh + docker build -t c2fmzq/sshterm-server:test . + docker run --rm --interactive c2fmzq/sshterm-server:test -v + + create-release: + name: Create release + needs: build-and-run-tests + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + # https://github.com/actions/checkout/tree/v4.1.1 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - name: Get GO version + id: goversion + run: echo "goversion=$(<.goversion)" > "${GITHUB_OUTPUT}" + - name: Set up Go + # https://github.com/actions/setup-go/tree/v6.2.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 + with: + go-version: ${{steps.goversion.outputs.goversion}} + - name: Build + run: ./build.sh - name: Extract release notes run: | @@ -69,15 +98,17 @@ jobs: gpg --batch --pinentry-mode loopback --yes --passphrase "${GPG_PASSPHRASE}" --detach-sig --local-user c2FmZQ-bot --output "${basename}.tar.gz.sig" "${basename}.tar.gz" export CGO_ENABLED=0 + export GOARM=7 flag="-ldflags=-extldflags=-static -s -w -X main.Version=${GITHUB_REF_NAME:-dev}" cd ../go for os in darwin linux; do - for arch in amd64 arm64; do + for arch in amd64 arm64 arm; do serverbin="sshterm-server-${os}-${arch}-${GITHUB_REF_NAME}" echo "Building ${serverbin}" - GOOS="${os}" GOARCH="${arch}" go build -a -trimpath "${flag}" -o "../bin/${serverbin}" ./sshterm-server - (cd ../bin && sha256sum "${serverbin}" | cut -d " " -f1 > "${serverbin}.sha256") - (cd ../bin && gpg --batch --pinentry-mode loopback --yes --passphrase "${GPG_PASSPHRASE}" --detach-sig --local-user c2FmZQ-bot --output "${serverbin}.sig" "${serverbin}") + if GOOS="${os}" GOARCH="${arch}" go build -a -trimpath "${flag}" -o "../bin/${serverbin}" ./sshterm-server; then + (cd ../bin && sha256sum "${serverbin}" | cut -d " " -f1 > "${serverbin}.sha256") + (cd ../bin && gpg --batch --pinentry-mode loopback --yes --passphrase "${GPG_PASSPHRASE}" --detach-sig --local-user c2FmZQ-bot --output "${serverbin}.sig" "${serverbin}") + fi done done @@ -95,3 +126,91 @@ jobs: body_path: release-notes.md fail_on_unmatched_files: true files: bin/* + + push-to-registry: + name: Push image to docker hub + needs: + - build-and-run-tests + - create-release + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Check out the repo + # https://github.com/actions/checkout/tree/v4.1.1 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + + - name: Get GO version + id: goversion + run: echo "goversion=$(<.goversion)" > "${GITHUB_OUTPUT}" + + - name: Set up Go + # https://github.com/actions/setup-go/tree/v6.2.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 + with: + go-version: ${{steps.goversion.outputs.goversion}} + + - name: Build docroot + run: ./build.sh + + - name: Install cosign + # https://github.com/sigstore/cosign-installer/releases/tag/v3.4.0 + uses: sigstore/cosign-installer@e1523de7571e31dbe865fd2e80c5c7c23ae71eb4 + + - name: Cosign version + run: cosign version + + - name: Log in to docker hub + # https://github.com/docker/login-action/tree/v3.0.0 + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Check golang image + run: ./scripts/check-golang-image.sh + + - name: Get release version + run: | + echo "VERSION=${GITHUB_REF_NAME}" > version.sh + + - name: Set up QEMU + # https://github.com/docker/setup-qemu-action/releases/tag/v3.0.0 + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 + + - name: Set up Docker Buildx + # https://github.com/docker/setup-buildx-action/releases/tag/v3.0.0 + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 + + - name: Extract metadata (tags, labels) for Docker + id: meta + # https://github.com/docker/metadata-action/releases/tag/v5.0.0 + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 + with: + images: c2fmzq/sshterm-server + flavor: ${{ (contains(github.ref, '+') || contains(github.ref, '-')) && 'latest=false' || 'latest=auto' }} + + - name: Build and push docker image + # https://github.com/docker/build-push-action/releases/tag/v5.1.0 + uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 + id: build-and-push + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64,linux/arm/v7 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Sign docker image + run: | + images="" + for tag in ${TAGS}; do + images+="${tag}@${DIGEST} " + done + cosign sign --yes ${images} + env: + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} diff --git a/.gitignore b/.gitignore index 0f26fab..64d28b7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ bin/ cache/ config.yaml tlsproxy.env +version.sh /sshterm-server go/sshterm-server/internal/docroot/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ae8792 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.27.3-alpine3.24 AS build +RUN apk update && apk upgrade +RUN apk add ca-certificates + +ADD . /app/go/src/sshterm +WORKDIR /app/go/src/sshterm +RUN if [ ! -f go/sshterm-server/internal/docroot/ssh.wasm ]; then apk add bash npm && ./build.sh; fi +WORKDIR /app/go/src/sshterm/go +RUN go mod download +RUN source ../version.sh && CGO_ENABLED=0 go install -ldflags="-s -w -X main.Version=${VERSION:-dev}" ./sshterm-server + +FROM scratch +WORKDIR / +COPY --from=build /etc/ssl /etc/ssl/ +COPY --from=build /usr/share/ca-certificates /usr/share/ca-certificates/ +COPY --from=build /go/bin/sshterm-server /bin/ + +EXPOSE 8080 +USER 1000:1000 +ENV GODEBUG="clobberfree=1,http2xconnect=0" + +ENTRYPOINT ["/bin/sshterm-server", "--addr=:8080"] diff --git a/README.md b/README.md index 7d68ae9..9e7a8b3 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,12 @@ You can get started by either downloading a pre-built release or by building the ### Installation -**Option 1: Download a Release** +**Option 1: Download a Release or Use Docker** -You can find pre-packaged releases on the [releases page](https://github.com/c2FmZQ/sshterm/releases). Download either: +You can find pre-packaged releases on the [releases page](https://github.com/c2FmZQ/sshterm/releases) or on [Docker Hub](https://hub.docker.com/r/c2fmzq/sshterm-server): * `sshterm-docroot-${VERSION}.tar.gz`: The static web application files, ready to be served by `tlsproxy` or another web server. -* `sshterm-server-${OS}-${ARCH}-${VERSION}`: The standalone `sshterm-server` binary (for Linux and macOS on `amd64` and `arm64`) with the web application embedded. +* `sshterm-server-${OS}-${ARCH}-${VERSION}`: The standalone `sshterm-server` binary (for Linux and macOS on `amd64`, `arm64`, and Linux `arm`) with the web application embedded. +* `c2fmzq/sshterm-server:latest`: Multi-architecture Docker image (`linux/amd64`, `linux/arm64`, `linux/arm/v7`) for `sshterm-server`. **Option 2: Build from Source** @@ -86,6 +87,17 @@ SSH Term connects to SSH servers via a WebSocket proxy. For full production depl --ep myserver=192.168.0.100:22 ``` +Or using Docker: + +```bash +docker run \ + --name=sshterm-server \ + --user=1000:1000 \ + --publish=127.0.0.1:8080:8080 \ + c2fmzq/sshterm-server:latest \ + --ep myserver=192.168.0.100:22 +``` + Available flags: * `--addr `: The TCP address to listen on (default `127.0.0.1:8080`). * `--ep ` (or `--endpoint`, repeatable): Defines a WebSocket endpoint at `/_ws/` forwarding to `host:port` (defaults to port `22` if omitted) and adds it to `/config.json`. diff --git a/go/sshterm-server/internal/server.go b/go/sshterm-server/internal/server.go index d69b6cb..6e088ac 100644 --- a/go/sshterm-server/internal/server.go +++ b/go/sshterm-server/internal/server.go @@ -33,6 +33,7 @@ import ( "io" "io/fs" "log" + "mime" "net" "net/http" "os" @@ -49,6 +50,24 @@ import ( //go:embed docroot var embeddedDocroot embed.FS +func init() { + for ext, typ := range map[string]string{ + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", + } { + _ = mime.AddExtensionType(ext, typ) + } +} + const ( defaultCSP = "default-src 'self'; style-src 'unsafe-inline' 'self'; script-src 'wasm-unsafe-eval' 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none';" diff --git a/go/sshterm-server/internal/server_test.go b/go/sshterm-server/internal/server_test.go index 652888d..07e9759 100644 --- a/go/sshterm-server/internal/server_test.go +++ b/go/sshterm-server/internal/server_test.go @@ -166,25 +166,41 @@ func TestEmbeddedStaticFilesAndHeaders(t *testing.T) { ts := httptest.NewServer(srv.Handler()) defer ts.Close() - for _, path := range []string{"/", "/index.html", "/ssh.wasm", "/xterm.mjs", "/stream-helper.js"} { - resp, err := http.Get(ts.URL + path) + for _, tc := range []struct { + path string + wantType string + }{ + {"/", "text/html; charset=utf-8"}, + {"/index.html", "text/html; charset=utf-8"}, + {"/ssh.wasm", "application/wasm"}, + {"/xterm.mjs", "text/javascript; charset=utf-8"}, + {"/stream-helper.js", "text/javascript; charset=utf-8"}, + {"/xterm.css", "text/css; charset=utf-8"}, + {"/ssh.png", "image/png"}, + {"/ssh.webmanifest", "application/manifest+json"}, + {"/LICENSE.xterm.txt", "text/plain; charset=utf-8"}, + } { + resp, err := http.Get(ts.URL + tc.path) if err != nil { - t.Fatalf("GET %s: %v", path, err) + t.Fatalf("GET %s: %v", tc.path, err) } _, _ = io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { - t.Errorf("GET %s: status = %d, want 200", path, resp.StatusCode) + t.Errorf("GET %s: status = %d, want 200", tc.path, resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != tc.wantType { + t.Errorf("GET %s: Content-Type = %q, want %q", tc.path, got, tc.wantType) } if got := resp.Header.Get("X-Frame-Options"); got != "DENY" { - t.Errorf("GET %s: X-Frame-Options = %q, want DENY", path, got) + t.Errorf("GET %s: X-Frame-Options = %q, want DENY", tc.path, got) } if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { - t.Errorf("GET %s: X-Content-Type-Options = %q, want nosniff", path, got) + t.Errorf("GET %s: X-Content-Type-Options = %q, want nosniff", tc.path, got) } if got := resp.Header.Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") { - t.Errorf("GET %s: Content-Security-Policy = %q, want frame-ancestors 'none'", path, got) + t.Errorf("GET %s: Content-Security-Policy = %q, want frame-ancestors 'none'", tc.path, got) } } diff --git a/scripts/check-golang-image.sh b/scripts/check-golang-image.sh new file mode 100755 index 0000000..e5163e7 --- /dev/null +++ b/scripts/check-golang-image.sh @@ -0,0 +1,17 @@ +#!/bin/bash -e +# +# This script checks that the base docker image for linux/amd64 is actually +# built for that platform. + +image=$(sed -n -re 's:^FROM ([^ ]+) AS build$:\1:p' Dockerfile) + +echo "Pulling ${image} for linux/amd64" +docker image pull --platform=linux/amd64 "${image}" + +arch=$(docker image inspect "${image}" | jq -r '.[].Architecture') + +if [[ "${arch}" != "amd64" ]]; then + echo "Image ${image} has platform=${arch}, want amd64" + exit 1 +fi +echo OK