Add Docker publishing, recommended installation and project history (#3052)

Document the shadowsocks-libev history and shadowsocks-c rename, and recommend Docker installation with mounted configuration and TCP/UDP port mappings.

Add native AMD64/ARM64 builds of the unprivileged static runtime image, real container relay and shutdown tests, and GHCR publication after both architecture checks pass. All 24 build/test checks pass at reviewed head 3f3f6bf8e01e5614821d1e15fda3ad8f07812902; publication is intentionally skipped on pull requests.
This commit is contained in:
Max Lv
2026-09-11 16:22:30 +08:00
committed by GitHub
parent bdc77408cb
commit bff687bd34
5 changed files with 360 additions and 28 deletions
+104
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"
+81 -27
View File
@@ -1,6 +1,6 @@
# shadowsocks-c
[![Build Status](https://travis-ci.com/shadowsocks/shadowsocks-libev.svg?branch=master)](https://travis-ci.com/shadowsocks/shadowsocks-libev) [![Snap Status](https://snapcraft.io/shadowsocks-libev/badge.svg)](https://snapcraft.io/shadowsocks-libev)
[![Build](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/build.yml) [![Tests](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/tests.yml) [![Portability](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/portability.yml/badge.svg?branch=master)](https://github.com/shadowsocks/shadowsocks-c/actions/workflows/portability.yml)
## Intro
@@ -13,6 +13,41 @@ created by [@clowwindy](https://github.com/clowwindy), and maintained by
Current version: 3.3.6 | [Changelog](debian/changelog)
## Project history and rename
This repository began as **shadowsocks-libev**, the lightweight C implementation
of Shadowsocks built around the libev event loop. It later entered a bug-fix-only
maintenance phase, with new development directed toward
[shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust).
In September 2026, the C implementation was modernized with a focus on
self-contained builds and portability. [The build modernization](https://github.com/shadowsocks/shadowsocks-c/pull/3050)
bundled pinned dependency sources, removed several external dependencies, and
added static-build validation across platforms.
[The subsequent migration](https://github.com/shadowsocks/shadowsocks-c/pull/3051)
replaced libev with libuv, added Windows IOCP and macOS kqueue support, expanded
asynchronous runtime DNS coverage, and strengthened CI lint checks.
The project and GitHub repository were renamed **shadowsocks-c** to reflect its
continuing identity as a pure C implementation. This repository retains the
shadowsocks-libev commit history and releases. The canonical repository is now
[shadowsocks/shadowsocks-c](https://github.com/shadowsocks/shadowsocks-c).
### Compatibility with shadowsocks-libev
- Commands such as `ss-local` and `ss-server`, the `shadowsocks.h` API, and the
embedding library ABI remain compatible.
- New builds provide `libshadowsocks-c` and the CMake/pkg-config package
`shadowsocks-c`. Legacy library filenames and the `shadowsocks-libev` package
lookup name remain available as compatibility aliases.
- Existing configuration paths, distribution package names, and service names
are retained. References to `shadowsocks-libev` in the installation examples
below refer to those existing integrations.
See [the modernization notes](docs/modernization.md) for build options and
platform support, and [the performance measurements](docs/performance.md) for
measured tradeoffs.
## Features
shadowsocks-c is written in pure C and depends on [libuv](https://libuv.org/). It's designed
@@ -21,35 +56,56 @@ to be a lightweight implementation of shadowsocks protocol, in order to keep the
For a full list of feature comparison between different versions of shadowsocks,
refer to the [Wiki page](https://github.com/shadowsocks/shadowsocks/wiki/Feature-Comparison-across-Different-Versions).
The project is now **shadowsocks-c**. Commands (`ss-local`, `ss-server`, etc.)
and the `shadowsocks.h` API remain compatible. New builds produce
`libshadowsocks-c` with CMake/pkg-config package `shadowsocks-c`; legacy library
filenames and package lookup names remain available as compatibility aliases.
Existing configuration paths, distro package names and service names are retained.
Repository links below still point to the current GitHub repository.
## Quick Start
Snap is the recommended way to install the latest binaries.
### 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
@@ -288,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
+7
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"]
+42 -1
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
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()