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:
104
.github/workflows/docker.yml
vendored
Normal file
104
.github/workflows/docker.yml
vendored
Normal 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"
|
||||
66
README.md
66
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
126
tests/docker_smoke.py
Normal 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()
|
||||
Reference in New Issue
Block a user