HH-620: require authentication before exposing setup
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
IMAGE_NAME=mohomo-docker:local
|
||||
CONTAINER_NAME=mohomo-docker
|
||||
SUBSCRIPTION_URL=https://subscription.example.invalid/mihomo
|
||||
SSCLASH_PASSWORD=
|
||||
WEB_BIND=0.0.0.0
|
||||
WEB_PORT=9091
|
||||
PROXY_BIND=0.0.0.0
|
||||
|
||||
@@ -6,27 +6,28 @@ Minimal Mihomo service with the ACL4SSR `Online Full MultiMode` routing model. T
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Replace only SUBSCRIPTION_URL in .env.
|
||||
# Generate a password, then set SUBSCRIPTION_URL and SSCLASH_PASSWORD in .env.
|
||||
openssl rand -base64 24
|
||||
docker compose up -d --build
|
||||
docker compose logs -f ssclash
|
||||
```
|
||||
|
||||
The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. Open `http://<server>:9091` to manage SSClash. Proxy clients connect to either endpoint:
|
||||
The subscription endpoint must return a Clash/Mihomo proxy-provider YAML document (`proxies:`). Use an HTTPS endpoint when its URL contains a credential. A fresh volume refuses to start without an `SSCLASH_PASSWORD` of at least 12 characters; bootstrap uses SSClash's own `setpass` command before the Web listener starts. Open `http://<server>:9091` and log in with that password. Existing authentication files are preserved, so later starts do not require or replace the password. Proxy clients connect to either endpoint:
|
||||
|
||||
```text
|
||||
HTTP proxy: http://<server>:7890
|
||||
SOCKS5 proxy: socks5://<server>:7890
|
||||
```
|
||||
|
||||
`WEB_BIND`, `WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides; both services bind all host interfaces by default. Set the SSClash administrator password and place the Web UI behind HTTPS and additional access control before exposing it to the Internet. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network.
|
||||
`WEB_BIND`, `WEB_PORT`, `PROXY_BIND`, and `PROXY_PORT` are optional deployment overrides; both services bind all host interfaces by default. Authentication prevents anonymous first-run setup, but the Web UI still serves plain HTTP: place it behind HTTPS and additional access control before exposing it to the Internet. Configure Mihomo proxy authentication before publishing port `7890` outside a trusted network.
|
||||
|
||||
## Update and secret handling
|
||||
|
||||
The bootstrap fetches the subscription once before startup and every hour thereafter. Each candidate is limited to 16 MiB and validated with the packaged Mihomo binary before an atomic replacement and hot reload. A failed reload restores and reloads the previous provider; if that recovery cannot be confirmed, both services stop instead of running with uncertain state.
|
||||
|
||||
The subscription URL is read from `SUBSCRIPTION_URL`, removed from child-process environments, and never printed. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials.
|
||||
The subscription URL and bootstrap administrator password are removed from child-process environments and never printed. The password is persisted only as SSClash's PBKDF2 authentication file. The generated configuration contains only a local provider path. Subscription data lives under `/dev/shm/mohomo`, so neither the image nor the `/opt/clash` volume stores its URL, response, or node credentials. Container restarts intentionally fetch a fresh subscription instead of persisting credentials.
|
||||
|
||||
Do not commit `.env`; it is ignored by Git. Docker still exposes container environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
|
||||
Do not commit `.env`; it is ignored by Git. Docker still exposes bootstrap environment variables to principals allowed to inspect the container, so restrict Docker daemon access.
|
||||
|
||||
## ACL4SSR rules
|
||||
|
||||
@@ -55,7 +56,7 @@ The GitHub Actions workflow builds `linux/amd64`, runs tests first, publishes on
|
||||
./tests/container-smoke.sh
|
||||
```
|
||||
|
||||
The unit suite checks atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test builds the image, validates the generated configuration, reaches the Web UI through its published port, checks that only ports `7890` and `9091` are published, and verifies that the subscription credential is neither persisted nor logged.
|
||||
The unit suite checks fail-closed authentication initialization, atomic rollback, URL redaction, server-only listeners, local ACL4SSR providers, and at least 65% bootstrap coverage. The container smoke test verifies that a fresh volume without an administrator password never starts the Web UI, then logs in through published port `9091`, checks the exact `7890`/`9091` port set, and confirms that plaintext credentials are neither persisted nor logged.
|
||||
|
||||
## License boundary
|
||||
|
||||
|
||||
@@ -33,12 +33,17 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap: runtime preparation failed: %v", err)
|
||||
}
|
||||
adminPasswordInitialized, err := bootstrap.EnsureAdminPassword(root, ssclashBinary, os.Getenv("SSCLASH_PASSWORD"))
|
||||
if err != nil {
|
||||
log.Fatalf("bootstrap: admin authentication setup failed: %v", err)
|
||||
}
|
||||
log.Printf(
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t",
|
||||
"bootstrap: ready root=%s core_initialized=%t config_initialized=%t server_settings_changed=%t admin_password_initialized=%t",
|
||||
root,
|
||||
result.CoreInitialized,
|
||||
result.ConfigInitialized,
|
||||
result.ServerSettingsChanged,
|
||||
adminPasswordInitialized,
|
||||
)
|
||||
|
||||
subscriptionURL := os.Getenv("SUBSCRIPTION_URL")
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
init: true
|
||||
environment:
|
||||
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:?set SUBSCRIPTION_URL in .env}
|
||||
SSCLASH_PASSWORD: ${SSCLASH_PASSWORD:-}
|
||||
ports:
|
||||
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-9091}:9091/tcp"
|
||||
- "${PROXY_BIND:-0.0.0.0}:${PROXY_PORT:-7890}:7890/tcp"
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxSubscriptionSize = 16 << 20
|
||||
const (
|
||||
maxSubscriptionSize = 16 << 20
|
||||
minAdminPasswordLength = 12
|
||||
)
|
||||
|
||||
var errMihomoStateUncertain = errors.New("Mihomo subscription state could not be restored")
|
||||
|
||||
@@ -101,6 +104,63 @@ func Prepare(config Config) (Result, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EnsureAdminPassword(root, binary, password string) (bool, error) {
|
||||
root = filepath.Clean(root)
|
||||
if root == "." || root == string(filepath.Separator) || !filepath.IsAbs(root) {
|
||||
return false, fmt.Errorf("unsafe root %q", root)
|
||||
}
|
||||
passwordPath := filepath.Join(root, ".ssclash", "password")
|
||||
configured, err := adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if configured {
|
||||
return false, nil
|
||||
}
|
||||
if password == "" {
|
||||
return false, errors.New("SSCLASH_PASSWORD is required to initialize a fresh volume")
|
||||
}
|
||||
if len(password) < minAdminPasswordLength {
|
||||
return false, fmt.Errorf("SSCLASH_PASSWORD must be at least %d characters", minAdminPasswordLength)
|
||||
}
|
||||
if err := validateSource(binary, "SSClash binary"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
command := exec.Command(binary, "setpass", password)
|
||||
command.Env = childEnvironment()
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
if err := command.Run(); err != nil {
|
||||
return false, errors.New("SSClash password initialization failed")
|
||||
}
|
||||
configured, err = adminPasswordConfigured(passwordPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !configured {
|
||||
return false, errors.New("SSClash password initialization did not create an authentication file")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func adminPasswordConfigured(path string) (bool, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect SSClash authentication file: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() == 0 {
|
||||
return false, errors.New("SSClash authentication file must be a non-empty regular file")
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
return false, fmt.Errorf("SSClash authentication file permissions are %o; want 600", info.Mode().Perm())
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, config RuntimeConfig) error {
|
||||
if err := validateSubscriptionURL(config.SubscriptionURL); err != nil {
|
||||
return err
|
||||
@@ -336,9 +396,10 @@ func childEnvironment() []string {
|
||||
environment := os.Environ()
|
||||
result := environment[:0]
|
||||
for _, entry := range environment {
|
||||
if !strings.HasPrefix(entry, "SUBSCRIPTION_URL=") {
|
||||
result = append(result, entry)
|
||||
if strings.HasPrefix(entry, "SUBSCRIPTION_URL=") || strings.HasPrefix(entry, "SSCLASH_PASSWORD=") {
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -90,6 +90,68 @@ func TestPreparePreservesUserDataAndForcesServerMode(t *testing.T) {
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "settings"), "LOG_LEVEL=debug\nOPERATING_MODE=server\nPROXY_MODE=none\n")
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordFailsClosedOnFreshVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := EnsureAdminPassword(t.TempDir(), "unused", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "SSCLASH_PASSWORD") {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v, want missing password error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdminPasswordInitializesOnlyWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join(t.TempDir(), "data")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".ssclash"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary := writeFixture(t, filepath.Join(root, "bin"), "ssclash", `#!/bin/sh
|
||||
set -eu
|
||||
[ "$1" = setpass ]
|
||||
[ "$2" = fresh-volume-password ]
|
||||
password="$(dirname "$0")/../.ssclash/password"
|
||||
printf 'pbkdf2$test\n' > "$password"
|
||||
chmod 0600 "$password"
|
||||
`)
|
||||
if err := os.Chmod(binary, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
initialized, err := EnsureAdminPassword(root, binary, "fresh-volume-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() error = %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("EnsureAdminPassword() initialized = false, want true")
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "password"), "pbkdf2$test\n")
|
||||
|
||||
if err := os.Remove(binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initialized, err = EnsureAdminPassword(root, binary, "replacement-password")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdminPassword() existing password error = %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("EnsureAdminPassword() replaced existing password")
|
||||
}
|
||||
assertFileContent(t, filepath.Join(root, ".ssclash", "password"), "pbkdf2$test\n")
|
||||
}
|
||||
|
||||
func TestChildEnvironmentRemovesCredentials(t *testing.T) {
|
||||
t.Setenv("SUBSCRIPTION_URL", "https://subscription.example.invalid/?token=secret")
|
||||
t.Setenv("SSCLASH_PASSWORD", "secret-password")
|
||||
|
||||
environment := strings.Join(childEnvironment(), "\n")
|
||||
for _, key := range []string{"SUBSCRIPTION_URL=", "SSCLASH_PASSWORD="} {
|
||||
if strings.Contains(environment, key) {
|
||||
t.Errorf("childEnvironment() retained %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsUnsafeOrAmbiguousState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -4,22 +4,26 @@ set -eu
|
||||
image=${1:-mohomo-docker:smoke}
|
||||
suffix="$$"
|
||||
container="mohomo-docker-smoke-${suffix}"
|
||||
unconfigured="mohomo-docker-unconfigured-${suffix}"
|
||||
provider="mohomo-provider-smoke-${suffix}"
|
||||
network="mohomo-network-smoke-${suffix}"
|
||||
volume="mohomo-volume-smoke-${suffix}"
|
||||
provider_dir=""
|
||||
cookie=""
|
||||
secret="container-smoke-secret"
|
||||
admin_password="container-smoke-admin-password"
|
||||
|
||||
case "$container:$provider:$network:$volume" in
|
||||
mohomo-docker-smoke-*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;;
|
||||
case "$container:$unconfigured:$provider:$network:$volume" in
|
||||
mohomo-docker-smoke-*':mohomo-docker-unconfigured-'*':mohomo-provider-smoke-'*':mohomo-network-smoke-'*':mohomo-volume-smoke-'*) ;;
|
||||
*) echo "refusing unsafe cleanup targets" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
cleanup() {
|
||||
docker container rm --force "$container" "$provider" >/dev/null 2>&1 || true
|
||||
docker container rm --force "$container" "$unconfigured" "$provider" >/dev/null 2>&1 || true
|
||||
docker volume rm "$volume" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
[ -z "$provider_dir" ] || rm -rf "$provider_dir"
|
||||
[ -z "$cookie" ] || rm -f "$cookie"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
@@ -58,12 +62,49 @@ until docker exec "$provider" wget -qO- http://127.0.0.1:8080/provider.yaml >/de
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
docker run --detach \
|
||||
--name "$unconfigured" \
|
||||
--network "$network" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges:true \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml" \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::9091/tcp \
|
||||
"$image" >/dev/null
|
||||
unconfigured_port=$(docker port "$unconfigured" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
attempt=0
|
||||
while [ "$(docker inspect --format '{{.State.Running}}' "$unconfigured")" = true ]; do
|
||||
if curl --fail --silent --show-error --max-time 1 \
|
||||
"http://127.0.0.1:${unconfigured_port}/setup" >/dev/null 2>&1; then
|
||||
echo "fresh volume exposed anonymous setup" >&2
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 10 ]; then
|
||||
echo "fresh volume did not fail closed without SSCLASH_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$(docker inspect --format '{{.State.ExitCode}}' "$unconfigured")" -eq 0 ]; then
|
||||
echo "fresh volume exited successfully without SSCLASH_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker logs "$unconfigured" 2>&1 | grep -F 'SSCLASH_PASSWORD is required' >/dev/null
|
||||
if docker logs "$unconfigured" 2>&1 | grep -F 'web UI listening' >/dev/null; then
|
||||
echo "fresh volume started the Web UI before authentication was configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker container rm "$unconfigured" >/dev/null
|
||||
|
||||
docker run --detach \
|
||||
--name "$container" \
|
||||
--network "$network" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges:true \
|
||||
--env "SUBSCRIPTION_URL=http://${provider}:8080/provider.yaml?token=${secret}" \
|
||||
--env "SSCLASH_PASSWORD=${admin_password}" \
|
||||
--volume "$volume:/opt/clash" \
|
||||
--publish 127.0.0.1::7890/tcp \
|
||||
--publish 127.0.0.1::7890/udp \
|
||||
@@ -90,7 +131,27 @@ if printf '%s\n' "$published" | grep -vE '^(7890/(tcp|udp)|9091/tcp)' >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
web_port=$(docker port "$container" 9091/tcp | awk -F: 'NR == 1 { print $NF }')
|
||||
curl --fail --silent --show-error "http://127.0.0.1:${web_port}/" >/dev/null
|
||||
setup_redirect=$(curl --silent --show-error --output /dev/null \
|
||||
--write-out '%{http_code} %{redirect_url}' \
|
||||
"http://127.0.0.1:${web_port}/setup")
|
||||
if [ "$setup_redirect" != "303 http://127.0.0.1:${web_port}/login" ]; then
|
||||
echo "configured Web UI exposed setup: ${setup_redirect}" >&2
|
||||
exit 1
|
||||
fi
|
||||
cookie=$(mktemp)
|
||||
login_html=$(curl --fail --silent --show-error --cookie-jar "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/login")
|
||||
login_csrf=$(printf '%s' "$login_html" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p' | head -1)
|
||||
test -n "$login_csrf"
|
||||
curl --fail --silent --show-error \
|
||||
--cookie "$cookie" \
|
||||
--cookie-jar "$cookie" \
|
||||
--request POST \
|
||||
--data-urlencode "csrf=${login_csrf}" \
|
||||
--data-urlencode "password=${admin_password}" \
|
||||
"http://127.0.0.1:${web_port}/login" >/dev/null
|
||||
curl --fail --silent --show-error --cookie "$cookie" \
|
||||
"http://127.0.0.1:${web_port}/config" | grep -F 'csrf-token' >/dev/null
|
||||
|
||||
docker exec "$container" grep -Fx 'OPERATING_MODE=server' /opt/clash/.ssclash/settings >/dev/null
|
||||
docker exec "$container" grep -Fx 'PROXY_MODE=none' /opt/clash/.ssclash/settings >/dev/null
|
||||
@@ -108,5 +169,13 @@ if docker logs "$container" 2>&1 | grep -F "$secret" >/dev/null; then
|
||||
echo "subscription URL credential was written to logs" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker exec "$container" grep -R -F "$admin_password" /opt/clash /dev/shm/mohomo >/dev/null 2>&1; then
|
||||
echo "administrator password was written to runtime files" >&2
|
||||
exit 1
|
||||
fi
|
||||
if docker logs "$container" 2>&1 | grep -F "$admin_password" >/dev/null; then
|
||||
echo "administrator password was written to logs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "container smoke test passed: only ports 7890 and 9091 published; subscription credential not persisted or logged"
|
||||
echo "container smoke test passed: fresh volume fails closed; authenticated 9091 and proxy credentials are protected"
|
||||
|
||||
Reference in New Issue
Block a user