1
0
mirror of https://github.com/shadowsocks/shadowsocks-libev.git synced 2026-09-24 00:45:06 +00:00

Add tested multi-platform Docker publishing and recommended installation

This commit is contained in:
Max Lv
2026-09-11 14:46:19 +08:00
parent ce76c0ea85
commit 3f3f6bf8e0
5 changed files with 324 additions and 22 deletions

104
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: docker
on:
pull_request:
branches: [master]
push:
branches: [master]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: ghcr.io/shadowsocks/shadowsocks-c
jobs:
image:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Build tested static runtime image
uses: docker/build-push-action@v7
with:
context: .
file: docker/static/Dockerfile
target: runtime
platforms: linux/${{ matrix.arch }}
load: true
tags: shadowsocks-c:test
labels: org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
- name: Exercise read-only, unprivileged runtime containers
run: python3 tests/docker_smoke.py --image shadowsocks-c:test
- name: Log in to GHCR
if: github.repository == 'shadowsocks/shadowsocks-c' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish tested architecture image
if: github.repository == 'shadowsocks/shadowsocks-c' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
env:
COMMIT: ${{ github.sha }}
ARCH: ${{ matrix.arch }}
run: |
docker tag shadowsocks-c:test "$IMAGE:sha-$COMMIT-$ARCH"
docker push "$IMAGE:sha-$COMMIT-$ARCH"
publish:
needs: image
if: github.repository == 'shadowsocks/shadowsocks-c' && github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Image tags
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=sha,format=long
type=semver,pattern={{version}}
- name: Publish multi-platform manifest
env:
COMMIT: ${{ github.sha }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
args=()
while IFS= read -r tag; do
if [ -n "$tag" ]; then args+=(--tag "$tag"); fi
done <<< "$TAGS"
docker buildx imagetools create "${args[@]}" \
"$IMAGE:sha-$COMMIT-amd64" "$IMAGE:sha-$COMMIT-arm64"
docker buildx imagetools inspect "$IMAGE:sha-$COMMIT"

View File

@@ -58,28 +58,54 @@ refer to the [Wiki page](https://github.com/shadowsocks/shadowsocks/wiki/Feature
## Quick Start
The Snap commands below use the existing `shadowsocks-libev` package name.
Distribution packages may predate the modernization; to build the current
source, follow [the CMake instructions](#build-from-source-cmake).
### Docker (recommended)
### Install snap core
Docker is the recommended way to run a server. The image contains the bundled,
fully static C binaries and supports Linux AMD64 and ARM64, including Linux
containers under Docker Desktop on macOS and Windows.
https://snapcraft.io/core
Create `config.json` and replace the example password with your own:
### Install from snapcraft.io
Stable channel:
```bash
sudo snap install shadowsocks-libev
```json
{
"server": "0.0.0.0",
"server_port": 8388,
"password": "replace-with-a-long-random-password",
"method": "aes-256-gcm",
"mode": "tcp_and_udp"
}
```
Edge channel:
In a POSIX shell, start the server with the configuration mounted read-only:
```bash
sudo snap install shadowsocks-libev --edge
```sh
docker pull ghcr.io/shadowsocks/shadowsocks-c:latest
docker run -d --name shadowsocks-c --restart unless-stopped \
--user "$(id -u):$(id -g)" --read-only --cap-drop=ALL \
--security-opt=no-new-privileges:true \
-p 8388:8388/tcp -p 8388:8388/udp \
--mount type=bind,src="$PWD/config.json",dst=/etc/shadowsocks-c/config.json,readonly \
ghcr.io/shadowsocks/shadowsocks-c:latest
```
Using your user ID lets the container read a configuration file owned by you.
View logs with `docker logs shadowsocks-c`; stop it with `docker stop shadowsocks-c`.
Configure your Shadowsocks client with the server address, port, password and
method above.
`latest` follows `master`; version tags and `sha-<full-commit>` tags identify
specific published builds. If the registry image is not yet available, build it
from this checkout with the same name, then run the command above without pulling:
```sh
docker build -f docker/static/Dockerfile --target runtime \
-t ghcr.io/shadowsocks/shadowsocks-c:latest .
```
See [Docker image details](docker/static/README.md) for publishing, updates,
client mode and build options. Existing Snap packages still use the
`shadowsocks-libev` name and may predate this modernization.
## Installation
### Distribution-specific guide
@@ -318,13 +344,11 @@ The historical Autotools scripts in `docker/mingw` are superseded by this build.
### Docker
As you expect, simply pull the image and run.
```
docker pull shadowsocks/shadowsocks-libev
docker run -e PASSWORD=<password> -p<server-port>:8388 -p<server-port>:8388/udp -d shadowsocks/shadowsocks-libev
```
More information about the image can be found [here](docker/alpine/README.md).
Use the [recommended Docker installation](#docker-recommended) above.
The image is `ghcr.io/shadowsocks/shadowsocks-c`; it accepts a JSON configuration
file or the normal `ss-server` arguments. The historical `PASSWORD` environment
variable wrapper belongs to the older Docker Hub image and is not used here.
See [image and build details](docker/static/README.md).
## Usage

View File

@@ -30,5 +30,12 @@ COPY --from=build /out/ /
# No libc, dynamic loader, package manager or shell in the runtime image.
FROM scratch AS runtime
LABEL org.opencontainers.image.title="shadowsocks-c" \
org.opencontainers.image.description="Self-contained Shadowsocks in C with libuv" \
org.opencontainers.image.source="https://github.com/shadowsocks/shadowsocks-c" \
org.opencontainers.image.licenses="GPL-3.0-or-later"
COPY --from=build /out/ /usr/local/
USER 65532:65532
EXPOSE 8388/tcp 8388/udp
ENTRYPOINT ["/usr/local/bin/ss-server"]
CMD ["-c", "/etc/shadowsocks-c/config.json"]

View File

@@ -1,4 +1,45 @@
# Fully static Linux builds
# Docker image and static Linux builds
Use `ghcr.io/shadowsocks/shadowsocks-c` for Linux AMD64 or ARM64. The
[README quick start](../../README.md#docker-recommended) shows server configuration,
TCP/UDP port mapping and a read-only configuration mount. Docker Desktop runs
these Linux images on macOS and Windows.
The default entrypoint is `ss-server -c /etc/shadowsocks-c/config.json`.
Passing arguments overrides that default configuration argument. The image runs
as UID/GID `65532:65532` unless `--user` is supplied; the mounted file must be
readable by that user. Logs go to standard output/error. No writable filesystem
is required for normal server operation.
To update a running server, pull the desired tag, remove the old container with
`docker rm -f shadowsocks-c`, and repeat the quick-start run command. The host
configuration file is preserved. `latest` tracks `master`; choose a published
version tag or `sha-<full-commit>` to pin a build.
For a SOCKS5 client, override the entrypoint with
`--entrypoint /usr/local/bin/ss-local`, supply a client configuration using `-c`,
and publish the configured local TCP/UDP port. Bind the local listener to
`0.0.0.0` inside the container and restrict its host mapping to loopback, such as
`-p 127.0.0.1:1080:1080/tcp -p 127.0.0.1:1080:1080/udp`.
## Publishing
`.github/workflows/docker.yml` builds each architecture on a native runner and
exercises the actual scratch server/client containers with concurrent TCP,
hostname resolution, UDP and clean shutdown. PRs run these checks without
publishing. Pushes to `master` publish `latest` and `sha-<full-commit>`; `v*`
version tags publish the version without the leading `v` and the commit tag.
The multi-platform tag is assembled only after both architecture tests pass.
Publishing uses the repository's `GITHUB_TOKEN` with `packages: write`.
The first image becomes available after this workflow lands on `master` and
completes. GitHub initially creates packages as private: a package administrator
must set `shadowsocks-c` to **Public** in its package settings before anonymous
pulls work. This is a one-time registry setting; see
[GitHub's container registry documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pushing-container-images).
Until then, the quick start also provides a local image build command.
## Build locally
The Alpine/musl builder uses Clang with LLD by default and links both third-party dependencies and libc statically.
`WITH_STATIC=ON` alone only selects static third-party libraries; this image also

126
tests/docker_smoke.py Normal file
View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Exercise the scratch server/client images with real TCP and UDP traffic."""
import argparse
import base64
import concurrent.futures
import json
import os
import socket
import time
from pathlib import Path
import subprocess
import tempfile
import threading
import uuid
import interop
def inside():
# All three containers share an isolated network namespace. Only loopback
# is needed, including for the server's localhost hostname resolution.
for port in (8388, 1080):
deadline = time.monotonic() + 15
while True:
try:
with socket.create_connection(('127.0.0.1', port), timeout=.2):
break
except OSError:
if time.monotonic() >= deadline:
raise AssertionError(f'container failed to listen on {port}')
time.sleep(.05)
with interop.TCPOrigin(('127.0.0.1', 0), interop.TCPHandler) as tcp, \
interop.UDPOrigin(('127.0.0.1', 0), interop.UDPHandler) as udp:
for origin in (tcp, udp):
threading.Thread(target=origin.serve_forever, daemon=True).start()
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(interop.tcp_case, 1080, tcp.server_address[1], size)
for size in (1, 65536, 1048576)]
for future in futures:
future.result(timeout=30)
interop.udp_case(1080, udp.server_address[1])
finally:
tcp.shutdown()
udp.shutdown()
print('PASS scratch image: concurrent TCP, hostname resolution, and UDP', flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--image', default='shadowsocks-c:test')
parser.add_argument('--python-image', default='python:3.12-alpine')
parser.add_argument('--inside', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args()
if args.inside:
inside()
return
root = Path(__file__).resolve().parent
name = 'ss-docker-test-' + uuid.uuid4().hex[:12]
helper = name + '-helper'
with tempfile.TemporaryDirectory(prefix='ss-docker-test-') as directory:
context = Path(directory)
for filename in ('interop.py', 'docker_smoke.py'):
(context / filename).write_bytes((root / filename).read_bytes())
(context / 'Dockerfile').write_text(
'ARG PYTHON_IMAGE=python:3.12-alpine\nFROM ${PYTHON_IMAGE}\n'
'COPY interop.py docker_smoke.py /tests/\n'
'USER 65532:65532\nENTRYPOINT ["python3", "/tests/docker_smoke.py", "--inside"]\n')
subprocess.run(['docker', 'build', '--build-arg', 'PYTHON_IMAGE=' + args.python_image,
'-t', helper, str(context)], check=True)
try:
for method in ('aes-256-gcm', '2022-blake3-aes-128-gcm'):
containers = []
volume = name + '-config'
key = base64.b64encode(os.urandom(16)).decode('ascii')
try:
common = ['docker', 'run', '-d', '--read-only', '--cap-drop=ALL',
'--security-opt=no-new-privileges:true']
server = name + '-server'
client = name + '-client'
subprocess.run(['docker', 'volume', 'create', volume], check=True,
stdout=subprocess.DEVNULL)
config = json.dumps({'server': '0.0.0.0', 'server_port': 8388,
'password': key, 'method': method, 'mode': 'tcp_and_udp'})
subprocess.run(['docker', 'run', '--rm', '-i', '--network', 'none',
'--user', '0:0', '--entrypoint', 'python3',
'--mount', 'type=volume,src=' + volume + ',dst=/config', helper,
'-c', 'import pathlib,sys; p=pathlib.Path("/config/config.json"); '
'p.write_text(sys.stdin.read()); p.chmod(0o644)'],
input=config, text=True, check=True)
subprocess.run(common + ['--name', server, '--network', 'none',
'--mount', 'type=volume,src=' + volume +
',dst=/etc/shadowsocks-c,readonly', args.image], check=True)
containers.append(server)
subprocess.run(common + ['--name', client, '--network', 'container:' + server,
'--entrypoint', '/usr/local/bin/ss-local', args.image,
'-s', '127.0.0.1', '-p', '8388', '-l', '1080', '-k', key,
'-m', method, '-u'], check=True)
containers.append(client)
# The helper waits for both listeners before generating traffic.
subprocess.run(['docker', 'run', '--rm', '--network', 'container:' + server,
'--read-only', '--cap-drop=ALL', helper], check=True, timeout=90)
for container in containers:
assert subprocess.check_output(['docker', 'inspect', '--format',
'{{.State.Running}}', container], text=True).strip() == 'true'
for container in reversed(containers):
subprocess.run(['docker', 'stop', '--time', '5', container], check=True,
stdout=subprocess.DEVNULL)
code = subprocess.check_output(['docker', 'inspect', '--format',
'{{.State.ExitCode}}', container], text=True).strip()
assert code == '0', f'{container}: unclean shutdown ({code})'
print('PASS runtime image: ' + method, flush=True)
except BaseException:
for container in containers:
subprocess.run(['docker', 'logs', container], check=False)
raise
finally:
for container in reversed(containers):
subprocess.run(['docker', 'rm', '-f', container], check=False, stdout=subprocess.DEVNULL)
subprocess.run(['docker', 'volume', 'rm', volume], check=False, stdout=subprocess.DEVNULL)
finally:
subprocess.run(['docker', 'image', 'rm', helper], check=False, stdout=subprocess.DEVNULL)
if __name__ == '__main__':
main()