Skip to content

Commit b25fc08

Browse files
committed
fix(install): use systemctl mask to block ALL Docker start attempts
Root cause: policy-rc.d only intercepts deb-systemd-invoke (dpkg postinst). get-docker.sh calls systemctl start docker DIRECTLY after apt-get completes, bypassing policy-rc.d entirely. Docker's TimeoutStartSec=0 means infinite block when dockerd cannot initialize on Ubuntu. Fix: - Add _mask_docker_services / _unmask_docker_services helpers - systemctl mask symlinks service files to /dev/null, causing ALL start attempts (both deb-systemd-invoke AND direct systemctl start) to fail immediately instead of blocking - Apply mask before get-docker.sh + apt-get, unmask before _start_docker - Keep policy-rc.d as additional safety net (belt and suspenders) Tests: 24/24 pass including new mask/unmask verification
1 parent e3787fb commit b25fc08

15 files changed

Lines changed: 158 additions & 339 deletions

.github/workflows/docker-build.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ jobs:
9696
no-cache: ${{ inputs.no_cache }}
9797
build-args: |
9898
WEBSOFT9_PRODUCT_VERSION=${{ steps.version.outputs.semver }}
99-
WEBSOFT9_PRODUCT_EDITION_KEY=${{ steps.version.outputs.edition }}
10099
WEBSOFT9_APPSTORE_CHANNEL=${{ inputs.appstore_channel }}
101100
cache-from: type=gha
102101
cache-to: type=gha,mode=max

apphub/src/api/v1/routers/settings.py

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
from fastapi import APIRouter, Query, Path, Cookie
44
from src.schemas.appSettings import AppSettings, PlatformGatewayBatchUpdateRequest, GenerateSelfSignedCertRequest, ApplyLetsEncryptCertRequest, UploadCertRequest
55
from src.schemas.errorResponse import ErrorResponse
6-
from src.schemas.productRuntimeState import ProductEditionStateResponse, ProductEditionUpdateRequest
6+
from src.schemas.productRuntimeState import ProductEditionStateResponse
77
from src.schemas.settingsSummary import SettingsSummaryResponse
88

99
from src.services.settings_manager import SettingsManager
1010
from src.services.product_auth import PRODUCT_AUTH_COOKIE_NAME, ProductAuthService
11-
from src.services.product_runtime_state import read_product_runtime_state, set_product_runtime_edition
11+
from src.services.product_runtime_state import read_product_runtime_state
1212

1313
router = APIRouter()
1414

@@ -151,40 +151,6 @@ def get_internal_product_edition_state(
151151
auth_service._require_authenticated_operator(session_token)
152152

153153
state = read_product_runtime_state()
154-
return ProductEditionStateResponse(
155-
version=state.version,
156-
edition_key=state.edition_key,
157-
edition_name=state.edition_name,
158-
max_apps=state.max_apps,
159-
state_source=state.state_source,
160-
updated_by=state.updated_by,
161-
updated_at=state.updated_at,
162-
note=state.note,
163-
)
164-
165-
166-
@router.put(
167-
"/settings/internal/product-edition",
168-
summary="Update runtime product edition state",
169-
description="Apply a runtime product edition change for authenticated operator workflows",
170-
responses={
171-
200: {"model": ProductEditionStateResponse},
172-
401: {"model": ErrorResponse},
173-
500: {"model": ErrorResponse},
174-
},
175-
)
176-
def update_internal_product_edition_state(
177-
payload: ProductEditionUpdateRequest,
178-
session_token: Optional[str] = Cookie(default=None, alias=PRODUCT_AUTH_COOKIE_NAME),
179-
):
180-
auth_service = ProductAuthService()
181-
actor = auth_service._require_authenticated_operator(session_token)
182-
183-
state = set_product_runtime_edition(
184-
payload.edition_key,
185-
updated_by=str(actor.get("username") or actor.get("id") or "support"),
186-
note=payload.note,
187-
)
188154
return ProductEditionStateResponse(
189155
version=state.version,
190156
edition_key=state.edition_key,

apphub/src/cli/apphub_cli.py

Lines changed: 30 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,22 @@
11
import configparser
22
import sys
33
import os
4-
import uuid
54
import json
6-
import shutil
7-
import requests
8-
import subprocess
5+
import re
96

107
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
118

129
import click
13-
from dotenv import dotenv_values, set_key,unset_key
14-
from src.services.apikey_manager import APIKeyManager
1510
from src.services.product_metadata import write_product_edition
1611
from src.services.settings_manager import SettingsManager
12+
from src.services.product_auth import ProductAuthService
1713
from src.core.exception import CustomException
18-
from src.core.config import ConfigManager
1914
from src.services.appstore_sync_manager import AppStoreSyncManager
20-
from src.services.integration_credentials import IntegrationCredentialProvider
2115

2216
@click.group()
2317
def cli():
2418
pass
2519

26-
@cli.command()
27-
def genkey():
28-
"""Generate a new API key"""
29-
try:
30-
key = APIKeyManager().generate_key()
31-
click.echo(f"{key}")
32-
except CustomException as e:
33-
raise click.ClickException(e.details)
34-
except Exception as e:
35-
raise click.ClickException(str(e))
36-
37-
@cli.command()
38-
def getkey():
39-
"""Get the API key"""
40-
try:
41-
key = APIKeyManager().get_key()
42-
click.echo(f"{key}")
43-
except CustomException as e:
44-
raise click.ClickException(e.details)
45-
except Exception as e:
46-
raise click.ClickException(str(e))
4720

4821
@cli.command()
4922
@click.option('--section',required=True, help='The section name')
@@ -88,41 +61,12 @@ def getconfig(section, key):
8861
config = configparser.ConfigParser()
8962
config.read(config_path, encoding="utf-8")
9063
if section is None:
91-
# 返回整个配置文件内容
92-
all_config = {s: dict(config.items(s)) for s in config.sections()}
93-
click.echo(json.dumps(all_config))
94-
elif key is None:
95-
# 返回指定 section 的内容
96-
value = dict(config.items(section)) if section in config.sections() else {}
97-
click.echo(json.dumps(value))
98-
else:
99-
# 返回指定 section 和 key 的内容
100-
value = config.get(section, key) if config.has_option(section, key) else ""
101-
click.echo(f"{value}")
102-
except CustomException as e:
103-
raise click.ClickException(e.details)
104-
except Exception as e:
105-
raise click.ClickException(str(e))
106-
107-
@cli.command()
108-
@click.option('--section', help='The section name')
109-
@click.option('--key', help='The key name')
110-
def getsysconfig(section, key):
111-
"""Get a system config value or all system config as JSON"""
112-
try:
113-
system_config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../config/system.ini'))
114-
config = configparser.ConfigParser()
115-
config.read(system_config_path, encoding="utf-8")
116-
if section is None:
117-
# 返回整个 system.ini 文件内容
11864
all_config = {s: dict(config.items(s)) for s in config.sections()}
11965
click.echo(json.dumps(all_config))
12066
elif key is None:
121-
# 返回指定 section 的内容
12267
value = dict(config.items(section)) if section in config.sections() else {}
12368
click.echo(json.dumps(value))
12469
else:
125-
# 返回指定 section 和 key 的内容
12670
value = config.get(section, key) if config.has_option(section, key) else ""
12771
click.echo(f"{value}")
12872
except CustomException as e:
@@ -131,124 +75,15 @@ def getsysconfig(section, key):
13175
raise click.ClickException(str(e))
13276

13377

134-
@cli.command()
135-
@click.option('--edition', 'edition_key', required=True, help='Edition key: free | starter | standard | enterprise')
136-
def setproductedition(edition_key):
78+
@cli.command(hidden=True)
79+
@click.argument('edition_key')
80+
def setedition(edition_key):
13781
"""Set runtime product edition state"""
13882
try:
13983
edition = write_product_edition(edition_key)
14084
click.echo(f"Set product edition to {edition.key} (max_apps={edition.max_apps})")
14185
except Exception as e:
14286
raise click.ClickException(str(e))
143-
144-
@cli.command()
145-
@click.option('--appid',required=True, help='The App Id')
146-
@click.option('--github_token', required=True, help='The Github Token')
147-
def commit(appid, github_token):
148-
"""Commit the app to the Github"""
149-
try:
150-
# 从配置文件读取gitea的用户名和密码
151-
credentials = IntegrationCredentialProvider().get_gitea_credentials()
152-
gitea_user = credentials.username
153-
gitea_pwd = credentials.password
154-
155-
# 将/tmp目录作为工作目录,如果不存在则创建,如果存在则清空
156-
work_dir = "/tmp/git"
157-
if os.path.exists(work_dir):
158-
shutil.rmtree(work_dir)
159-
os.makedirs(work_dir)
160-
os.chdir(work_dir)
161-
162-
# 执行git clone命令:将gitea仓库克隆到本地
163-
gitea_repo_url = f"http://{gitea_user}:{gitea_pwd}@websoft9-git:3000/websoft9/{appid}.git"
164-
subprocess.run(["git", "clone", gitea_repo_url], check=True)
165-
166-
# 执行git clone命令:将github仓库克隆到本地(dev分支)
167-
github_repo_url = f"https://github.com/Websoft9/docker-library.git"
168-
subprocess.run(["git", "clone", "--branch", "dev", github_repo_url], check=True)
169-
170-
# 解析gitea_repo_url下载的目录下的.env文件
171-
gitea_env_path = os.path.join(work_dir, appid, '.env')
172-
gitea_env_vars = dotenv_values(gitea_env_path)
173-
w9_app_name = gitea_env_vars.get('W9_APP_NAME')
174-
175-
if not w9_app_name:
176-
raise click.ClickException("W9_APP_NAME not found in Gitea .env file")
177-
178-
# 解析github_repo_url下载的目录下的/apps/W9_APP_NAME目录下的.env文件
179-
github_env_path = os.path.join(work_dir, 'docker-library', 'apps', w9_app_name, '.env')
180-
github_env_vars = dotenv_values(github_env_path)
181-
182-
# 需要复制的变量
183-
env_vars_to_copy = ['W9_URL', 'W9_ID']
184-
port_set_vars = {key: value for key, value in github_env_vars.items() if key.endswith('PORT_SET')}
185-
186-
# 将这些值去替换gitea_repo_url目录下.env中对应项的值
187-
for key in env_vars_to_copy:
188-
if key in github_env_vars:
189-
set_key(gitea_env_path, key, github_env_vars[key])
190-
191-
for key, value in port_set_vars.items():
192-
set_key(gitea_env_path, key, value)
193-
194-
# 删除W9_APP_NAME
195-
unset_key(gitea_env_path, 'W9_APP_NAME')
196-
197-
# 将整个gitea目录覆盖到docker-library/apps/w9_app_name目录
198-
gitea_repo_dir = os.path.join(work_dir, appid)
199-
github_app_dir = os.path.join(work_dir, 'docker-library', 'apps', w9_app_name)
200-
if os.path.exists(github_app_dir):
201-
shutil.rmtree(github_app_dir)
202-
shutil.copytree(gitea_repo_dir, github_app_dir)
203-
204-
# 切换到docker-library目录
205-
os.chdir(os.path.join(work_dir, 'docker-library'))
206-
207-
# 创建一个新的分支
208-
new_branch_name = f"update-{w9_app_name}-{uuid.uuid4().hex[:8]}"
209-
subprocess.run(["git", "checkout", "-b", new_branch_name], check=True)
210-
211-
# 将修改提交到新的分支
212-
subprocess.run(["git", "add", "."], check=True)
213-
subprocess.run(["git", "commit", "-m", f"Update {w9_app_name}"], check=True)
214-
215-
# 推送新的分支到 GitHub
216-
# subprocess.run(["git", "push", "origin", new_branch_name], check=True)
217-
218-
# 推送新的分支到 GitHub
219-
github_push_url = f"https://{github_token}:x-oauth-basic@github.com/websoft9/docker-library.git"
220-
subprocess.run(["git", "push", github_push_url, new_branch_name], check=True)
221-
222-
# 创建 Pull Request 使用 GitHub API
223-
pr_data = {
224-
"title": f"Update {w9_app_name}",
225-
"head": new_branch_name,
226-
"base": "dev",
227-
"body": "Automated update"
228-
}
229-
230-
response = requests.post(
231-
f"https://api.github.com/repos/websoft9/docker-library/pulls",
232-
headers={
233-
"Authorization": f"token {github_token}",
234-
"Accept": "application/vnd.github.v3+json"
235-
},
236-
data=json.dumps(pr_data)
237-
)
238-
239-
if response.status_code != 201:
240-
raise click.ClickException(f"Failed to create Pull Request: {response.json()}")
241-
242-
click.echo(f"Pull Request created: {response.json().get('html_url')}")
243-
244-
except subprocess.CalledProcessError as e:
245-
raise click.ClickException(f"Command failed: {e}")
246-
except Exception as e:
247-
raise click.ClickException(str(e))
248-
finally:
249-
# 删除工作目录
250-
if os.path.exists(work_dir):
251-
shutil.rmtree(work_dir)
25287

25388
@cli.command()
25489
@click.argument('target', required=True, type=click.Choice(['apps'], case_sensitive=False))
@@ -278,47 +113,36 @@ def upgrade(target, channel, dev, force_refresh):
278113
click.echo(f"App Store resources ({active_channel}) synchronized successfully.")
279114
else:
280115
click.echo(f"Unknown upgrade target: {target}")
281-
except subprocess.CalledProcessError as e:
282-
raise click.ClickException(f"Upgrade command failed: {e}")
283116
except Exception as e:
284117
raise click.ClickException(str(e))
285118

286119

287-
@cli.command(name='appstore-versions')
288-
def appstore_versions():
289-
"""List locally available App Store dataset versions"""
120+
@cli.command(hidden=True)
121+
@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, help='New password for the system user')
122+
def resetpwd(password):
123+
"""Reset the Websoft9 system user password"""
290124
try:
291-
result = AppStoreSyncManager().list_versions()
292-
click.echo(json.dumps(result))
293-
except Exception as e:
294-
raise click.ClickException(str(e))
295-
296-
297-
@cli.command(name='activate-appstore')
298-
@click.option('--dataset-version', required=True, help='Activate the specified local App Store dataset version')
299-
def activate_appstore(dataset_version):
300-
"""Activate a locally available App Store dataset version"""
301-
try:
302-
result = AppStoreSyncManager().activate(dataset_version=dataset_version, trigger='cli')
303-
click.echo(f"Activated App Store dataset version: {result.get('datasetVersion')}")
304-
except Exception as e:
305-
raise click.ClickException(str(e))
306-
307-
@cli.command()
308-
def getallconfig():
309-
"""Get all config.ini and system.ini data as JSON"""
310-
try:
311-
config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../config/config.ini'))
312-
system_config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../config/system.ini'))
313-
config = configparser.ConfigParser()
314-
system_config = configparser.ConfigParser()
315-
config.read(config_path, encoding="utf-8")
316-
system_config.read(system_config_path, encoding="utf-8")
317-
result = {
318-
"config": {s: dict(config.items(s)) for s in config.sections()},
319-
"system": {s: dict(system_config.items(s)) for s in system_config.sections()}
320-
}
321-
click.echo(json.dumps(result))
125+
if len(password) < 8:
126+
raise click.ClickException("Password must be at least 8 characters")
127+
if not re.search(r"[A-Z]", password) or not re.search(r"[a-z]", password) or not re.search(r"\d", password) or not re.search(r"[^A-Za-z0-9]", password):
128+
raise click.ClickException("Password must include uppercase, lowercase, number, and special character")
129+
130+
auth = ProductAuthService()
131+
system_user = auth.find_system_user()
132+
if system_user is None:
133+
raise click.ClickException("System user not found")
134+
135+
username = system_user['username']
136+
display_name = system_user.get('display_name', username)
137+
click.echo(f"\nSystem user: {username} ({display_name})")
138+
if not click.confirm("Reset password for this user?"):
139+
click.echo("Cancelled.")
140+
return
141+
142+
auth.reset_system_user_password(system_user['id'], password)
143+
click.echo(f"Password reset for system user '{username}'")
144+
except click.ClickException:
145+
raise
322146
except Exception as e:
323147
raise click.ClickException(str(e))
324148

apphub/src/config/product_metadata.json

Lines changed: 0 additions & 5 deletions
This file was deleted.

apphub/src/schemas/productRuntimeState.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@
33
from pydantic import BaseModel, Field
44

55

6-
class ProductEditionUpdateRequest(BaseModel):
7-
edition_key: str = Field(..., description="Target edition key")
8-
note: Optional[str] = Field(default=None, description="Optional support note")
9-
10-
116
class ProductEditionStateResponse(BaseModel):
127
version: Optional[str] = Field(default=None, description="Current program version")
138
edition_key: str = Field(..., description="Current effective edition key")

0 commit comments

Comments
 (0)