5 Commits

Author SHA1 Message Date
robonen ccaf521da4 feat: update syshelper version to 0.2.0 and enhance firewall rules in installer
CI / Windows x64 (push) Successful in 10m9s
2026-08-02 22:21:49 +07:00
robonen dfecd5dafb feat: bump syshelper version to 0.1.1 in Cargo.lock
CI / Windows x64 (push) Successful in 9m20s
2026-08-02 21:00:35 +07:00
robonen 6d01e5ccc2 feat: bump version to 0.1.1 in Cargo.toml
CI / Windows x64 (push) Has been cancelled
2026-08-02 20:51:08 +07:00
robonen 8da02e2392 feat: improve service start logic in installer to ensure proper error handling and feedback
CI / Windows x64 (push) Has been cancelled
2026-08-02 20:48:12 +07:00
robonen dd5f418ee0 feat: enhance release publishing logic in CI workflow to handle existing releases and asset management
CI / Windows x64 (push) Successful in 8m52s
2026-08-02 19:18:58 +07:00
5 changed files with 115 additions and 31 deletions
+41 -18
View File
@@ -135,6 +135,16 @@ jobs:
# Releases go through the Gitea API directly: it is GitHub-compatible in # Releases go through the Gitea API directly: it is GitHub-compatible in
# shape, but a self-hosted instance may not have ready-made actions. # shape, but a self-hosted instance may not have ready-made actions.
#
# The step is written to be repeatable. Creating a release in the Gitea
# web UI also creates the tag, so by the time this runs the release
# usually exists already and a blind POST would fail with 409. The same
# goes for re-running a build over a tag whose assets are in place.
#
# Keep it POSIX: the runner executes steps with dash, so no `pipefail`.
# curl calls therefore stay out of pipelines — under `set -e` a failed
# command substitution aborts the step, which is what pipefail bought us.
# No -x anywhere here: these commands carry the token.
- name: Publish release - name: Publish release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
@@ -142,29 +152,42 @@ jobs:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
# Keep this POSIX: the runner executes steps with dash, where
# `set -o pipefail` does not exist. So the curl call is kept out of a
# pipeline — under `set -e` a failed command substitution aborts the
# step, which is what pipefail would have bought us. Without that, a
# failing curl would be masked by a successful jq, id would become
# null, and assets would be uploaded to a release that never existed.
# No -x here: the command carries the token.
set -eu set -eu
response=$(curl -fsS -X POST "$API/releases" \ auth="Authorization: token $TOKEN"
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}")
id=$(printf '%s' "$response" | jq -r '.id // empty') # Reuse the release if it is already there, create it otherwise.
existing=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
-H "$auth" "$API/releases/tags/$TAG")
if [ "$existing" = "200" ]; then
echo "release $TAG already exists, attaching assets to it"
else
echo "creating release $TAG"
curl -fsS -o /tmp/release.json -X POST "$API/releases" \
-H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}"
fi
id=$(jq -r '.id // empty' /tmp/release.json)
if [ -z "$id" ]; then if [ -z "$id" ]; then
echo "no release id in response: $response" >&2 echo "could not resolve release id for $TAG:" >&2
cat /tmp/release.json >&2
exit 1 exit 1
fi fi
echo "release $TAG (id $id)"
for file in dist/*.exe; do for file in dist/*.exe; do
echo "uploading $(basename "$file")" name=$(basename "$file")
curl -fsS -X POST "$API/releases/$id/assets?name=$(basename "$file")" \
-H "Authorization: token $TOKEN" \ # Replace an asset of the same name rather than ending up with two.
-F "attachment=@$file" > /dev/null curl -fsS -o /tmp/assets.json -H "$auth" "$API/releases/$id/assets"
for old in $(jq -r --arg n "$name" '.[] | select(.name==$n) | .id' /tmp/assets.json); do
echo "removing previous $name (asset $old)"
curl -fsS -X DELETE -H "$auth" "$API/releases/$id/assets/$old" > /dev/null
done
echo "uploading $name"
curl -fsS -X POST "$API/releases/$id/assets?name=$name" \
-H "$auth" -F "attachment=@$file" > /dev/null
done done
echo "release $TAG (id $id) is ready"
Generated
+1 -1
View File
@@ -1224,7 +1224,7 @@ dependencies = [
[[package]] [[package]]
name = "syshelper" name = "syshelper"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "syshelper" name = "syshelper"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
+59 -3
View File
@@ -185,7 +185,18 @@ begin
SW_HIDE, ewWaitUntilTerminated, Code); SW_HIDE, ewWaitUntilTerminated, Code);
end; end;
procedure InstallService(); // Запускаем через net start, а не sc start, по той же причине, что и остановку:
// net дожидается фактического запуска и возвращает ошибку, если он не удался.
// sc отдаёт управление сразу, и сломанная установка выглядела бы успешной.
function StartService(): Boolean;
var
Code: Integer;
begin
Result := Exec(ExpandConstant('{sys}\net.exe'), 'start {#ServiceName}', '',
SW_HIDE, ewWaitUntilTerminated, Code) and (Code = 0);
end;
function InstallService(): Boolean;
var var
Bin: String; Bin: String;
begin begin
@@ -204,7 +215,8 @@ begin
RunSc('description {#ServiceName} "Родительский контроль: следит за запуском программ."'); RunSc('description {#ServiceName} "Родительский контроль: следит за запуском программ."');
// Родительский контроль не должен исчезать из-за одной аварии. // Родительский контроль не должен исчезать из-за одной аварии.
RunSc('failure {#ServiceName} reset= 86400 actions= restart/5000/restart/5000/restart/60000'); RunSc('failure {#ServiceName} reset= 86400 actions= restart/5000/restart/5000/restart/60000');
RunSc('start {#ServiceName}');
Result := StartService();
end; end;
// Каталог данных закрываем от обычных пользователей: унаследованные от // Каталог данных закрываем от обычных пользователей: унаследованные от
@@ -222,7 +234,34 @@ begin
'', SW_HIDE, ewWaitUntilTerminated, Code); '', SW_HIDE, ewWaitUntilTerminated, Code);
end; end;
// Без правила брандмауэра панель доступна только с самого компьютера: Windows
// режет входящие подключения по умолчанию. Ограничиваемся частными и доменными
// сетями — в публичной (кафе, вокзал) панель наружу смотреть не должна.
procedure RemoveFirewallRule();
var
Code: Integer;
begin
Exec(ExpandConstant('{sys}\netsh.exe'),
'advfirewall firewall delete rule name="{#AppName}"',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure OpenFirewallPort();
var
Code: Integer;
begin
// сначала снимаем старое правило, иначе при переустановке они накопятся
RemoveFirewallRule();
Exec(ExpandConstant('{sys}\netsh.exe'),
'advfirewall firewall add rule name="{#AppName}" dir=in action=allow' +
' protocol=TCP localport=8787 profile=private,domain' +
' program="' + ExpandConstant('{app}\{#AppExeName}') + '"',
'', SW_HIDE, ewWaitUntilTerminated, Code);
end;
procedure CurStepChanged(CurStep: TSetupStep); procedure CurStepChanged(CurStep: TSetupStep);
var
Started: Boolean;
begin begin
if CurStep <> ssPostInstall then if CurStep <> ssPostInstall then
Exit; Exit;
@@ -231,7 +270,23 @@ begin
// только затем поднимаем службу. // только затем поднимаем службу.
LockDownDataDir(); LockDownDataDir();
SetPassword(); SetPassword();
InstallService(); OpenFirewallPort();
Started := InstallService();
// Обе проверки — про молчаливый отказ: без них установка отрапортует об
// успехе, а защиты не будет. При тихой установке (автообновление) человека
// за экраном нет, и разбираться придётся по журналу Inno, /LOG= его пишет.
if WizardSilent() then
Exit;
if not AuthConfigured() then
MsgBox('Не удалось сохранить пароль панели.'#13#10 +
'Панель откроется без пароля, и задать его сможет любой, ' +
'кто зайдёт первым.', mbError, MB_OK);
if not Started then
MsgBox('Служба не запустилась — контроль сейчас не работает.'#13#10 +
'Откройте «Службы» и посмотрите System Helper.', mbError, MB_OK);
end; end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
@@ -241,4 +296,5 @@ begin
StopService(); StopService();
RunSc('delete {#ServiceName}'); RunSc('delete {#ServiceName}');
RemoveFirewallRule();
end; end;
+13 -8
View File
@@ -33,7 +33,12 @@ mod auth;
mod service; mod service;
mod update; mod update;
const ADDR: &str = "127.0.0.1:8787"; /// Слушаем все интерфейсы, чтобы панель открывалась и с телефона или другого
/// компьютера в домашней сети. Правило брандмауэра ставит установщик, и только
/// для частных сетей — в публичной панель наружу смотреть не должна.
const BIND_ADDR: &str = "0.0.0.0:8787";
/// Адрес для ярлыков и сообщений: 0.0.0.0 в браузер не введёшь.
const PANEL_URL: &str = "http://127.0.0.1:8787";
/// Служебные команды не должны мигать консольным окном. /// Служебные команды не должны мигать консольным окном.
#[cfg(windows)] #[cfg(windows)]
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -125,19 +130,17 @@ fn stamp() -> String {
/// Открывает панель в браузере. Своего окна у программы нет, поэтому запуск /// Открывает панель в браузере. Своего окна у программы нет, поэтому запуск
/// ярлыком без этого выглядел бы как «ничего не произошло». /// ярлыком без этого выглядел бы как «ничего не произошло».
fn open_panel() { fn open_panel() {
let url = format!("http://{ADDR}");
#[cfg(windows)] #[cfg(windows)]
{ {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
// пустой заголовок обязателен: иначе start примет URL за имя окна // пустой заголовок обязателен: иначе start примет URL за имя окна
let _ = std::process::Command::new("cmd") let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &url]) .args(["/C", "start", "", PANEL_URL])
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.spawn(); .spawn();
} }
#[cfg(not(windows))] #[cfg(not(windows))]
let _ = std::process::Command::new("open").arg(&url).spawn(); let _ = std::process::Command::new("open").arg(PANEL_URL).spawn();
} }
/// Окна нет, поэтому паника иначе прошла бы совершенно незаметно: процесс /// Окна нет, поэтому паника иначе прошла бы совершенно незаметно: процесс
@@ -612,12 +615,12 @@ async fn bind_panel(
) -> Option<tokio::net::TcpListener> { ) -> Option<tokio::net::TcpListener> {
let mut complained = false; let mut complained = false;
loop { loop {
match tokio::net::TcpListener::bind(ADDR).await { match tokio::net::TcpListener::bind(BIND_ADDR).await {
Ok(listener) => return Some(listener), Ok(listener) => return Some(listener),
Err(e) if !complained => { Err(e) if !complained => {
complained = true; // в журнал раз, а не каждые полминуты complained = true; // в журнал раз, а не каждые полминуты
state state
.log(format!("{ADDR} занят ({e}) — жду, правила при этом работают")) .log(format!("{BIND_ADDR} занят ({e}) — жду, правила при этом работают"))
.await; .await;
} }
Err(_) => {} Err(_) => {}
@@ -681,7 +684,9 @@ async fn serve(shutdown: tokio::sync::watch::Receiver<bool>) {
let Some(listener) = bind_panel(&state, shutdown.clone()).await else { let Some(listener) = bind_panel(&state, shutdown.clone()).await else {
return; // службу остановили, пока ждали порт return; // службу остановили, пока ждали порт
}; };
state.log(format!("панель на http://{ADDR}")).await; state
.log(format!("панель на {PANEL_URL} и на порту 8787 в локальной сети"))
.await;
let mut stop = shutdown.clone(); let mut stop = shutdown.clone();
let result = axum::serve(listener, app) let result = axum::serve(listener, app)