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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.git
bin
output
xterm/node_modules
sshterm-server
6 changes: 6 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
pull_request:
paths:
- '.github/workflows/*'
- 'Dockerfile'
- '.goversion'
- 'go/go.*'
- '**.go'
Expand Down Expand Up @@ -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
135 changes: 127 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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

Expand All @@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ bin/
cache/
config.yaml
tlsproxy.env
version.sh
/sshterm-server
go/sshterm-server/internal/docroot/
22 changes: 22 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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 <ip:port>`: The TCP address to listen on (default `127.0.0.1:8080`).
* `--ep <name=host[:port]>` (or `--endpoint`, repeatable): Defines a WebSocket endpoint at `/_ws/<name>` forwarding to `host:port` (defaults to port `22` if omitted) and adds it to `/config.json`.
Expand Down
19 changes: 19 additions & 0 deletions go/sshterm-server/internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"io"
"io/fs"
"log"
"mime"
"net"
"net/http"
"os"
Expand All @@ -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';"

Expand Down
30 changes: 23 additions & 7 deletions go/sshterm-server/internal/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
17 changes: 17 additions & 0 deletions scripts/check-golang-image.sh
Original file line number Diff line number Diff line change
@@ -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
Loading