Skip to content

Commit c113cd3

Browse files
committed
feat(sec/bootstrap,ci,docs): policy lib wiring, secure bootstrap, path fixes, MOTD/docs sync
1 parent a5842a6 commit c113cd3

File tree

11 files changed

+426
-20
lines changed

11 files changed

+426
-20
lines changed

.github/workflows/shellcheck.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: ShellCheck Lint 🧪
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'serverutils'
8+
- 'Scripts/**'
9+
- 'site/run.sh'
10+
- 'ci/**'
11+
pull_request:
12+
branches: [ main ]
13+
paths:
14+
- 'serverutils'
15+
- 'Scripts/**'
16+
- 'site/run.sh'
17+
- 'ci/**'
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
shellcheck:
24+
name: ShellCheck (bash lints)
25+
runs-on: ubuntu-latest
26+
continue-on-error: true
27+
steps:
28+
- name: Check out code
29+
uses: actions/checkout@v4
30+
- name: Install shellcheck
31+
run: |
32+
sudo apt-get update -y
33+
sudo apt-get install -y shellcheck
34+
- name: Run shellcheck
35+
run: |
36+
set -euo pipefail
37+
echo "Linting shell scripts with shellcheck"
38+
shellcheck -V
39+
# Lint top-level runner and site bootstrap
40+
shellcheck -e SC1090 -e SC1091 serverutils site/run.sh || true
41+
# Lint Scripts and CI scripts
42+
find Scripts -type f -name '*.sh' -print0 | xargs -0 -r shellcheck -e SC1090 -e SC1091 || true
43+
find ci -type f -name '*.sh' -print0 | xargs -0 -r shellcheck -e SC1090 -e SC1091 || true
44+

.github/workflows/static.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ jobs:
4444
test -f Scripts/lib/solen.sh
4545
test -f Scripts/lib/edit.sh
4646
test -f Scripts/lib/pm.sh
47+
test -f Scripts/lib/policy.sh
4748
# Update scripts present
4849
test -f Scripts/update/check.sh
4950
test -f Scripts/update/apply.sh
@@ -60,6 +61,7 @@ jobs:
6061
grep -Fxq 'Scripts/lib/solen.sh' "$tmp_tar_list"
6162
grep -Fxq 'Scripts/lib/edit.sh' "$tmp_tar_list"
6263
grep -Fxq 'Scripts/lib/pm.sh' "$tmp_tar_list"
64+
grep -Fxq 'Scripts/lib/policy.sh' "$tmp_tar_list"
6365
rm -f "$tmp_tar_list"
6466
# Create channel pointers and legacy latest name
6567
cp -f "$ver_tar" site/releases/solen-stable.tar.gz
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Validate JSON Outputs ✅
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'serverutils'
8+
- 'Scripts/**'
9+
- 'docs/**'
10+
- 'ci/**'
11+
pull_request:
12+
branches: [ main ]
13+
paths:
14+
- 'serverutils'
15+
- 'Scripts/**'
16+
- 'docs/**'
17+
- 'ci/**'
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
validate-json:
24+
name: Validate NDJSON contracts
25+
runs-on: ubuntu-latest
26+
steps:
27+
- name: Check out code
28+
uses: actions/checkout@v4
29+
- name: Install dependencies
30+
run: |
31+
sudo apt-get update -y
32+
sudo apt-get install -y jq
33+
- name: Run validation script
34+
run: |
35+
bash ci/validate-json.sh
36+

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ serverutils # TUI
4040
serverutils list # scripts with summaries
4141
```
4242

43+
Verify the install:
44+
45+
```bash
46+
serverutils status # installed vs latest version
47+
serverutils list --long # categories with one-line summaries
48+
serverutils run health/check -- --json # quick JSON health snapshot
49+
```
50+
4351
Developers — clone instead of curl:
4452

4553
```bash
@@ -94,6 +102,9 @@ Security:
94102
- Manifests include sha256 and can be signed in CI. To enforce verification, set `SOLEN_SIGN_PUBKEY_PEM` (PEM public key)
95103
and optional `SOLEN_REQUIRE_SIGNATURE=1` on hosts.
96104
- Updates stage to a temp dir, copy into `~/.local/share/solen/latest`, and keep `latest-prev` for rollback.
105+
- The bootstrap also supports channels and signature verification:
106+
- `bash <(curl -sL https://solen.shinni.dev/run.sh) --channel stable --user --with-motd --yes`
107+
- When `SOLEN_SIGN_PUBKEY_PEM` is set, the manifest signature is verified before applying.
97108

98109
---
99110

@@ -236,6 +247,25 @@ Tips
236247
* `SOLEN_NOOP=1` forces dry-run behavior globally.
237248
* `SOLEN_LOG_DIR=/path` overrides the default log base (`/var/log/solen` or `~/.local/share/solen`).
238249
* `SOLEN_ASSUME_YES=1` bypasses confirmation prompts.
250+
* Supported distros: Debian/Ubuntu first-class; Fedora/Arch/openSUSE best-effort via the package abstraction.
251+
252+
---
253+
254+
## ♻️ Uninstall
255+
256+
Per‑user uninstall (runner symlinks and hooks):
257+
258+
```bash
259+
serverutils install --uninstall --user --yes
260+
```
261+
262+
Remove everything installed by SOLEN (user scope):
263+
264+
```bash
265+
serverutils install --uninstall-everything --user --yes
266+
```
267+
268+
System‑wide variants use `--global` and may require `sudo`.
239269

240270
---
241271

Scripts/lib/policy.sh

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
#!/usr/bin/env bash
2+
# Minimal policy helpers for SOLEN
3+
# Looks for a YAML policy file and answers allow/deny queries.
4+
#
5+
# Policy resolution precedence (first found wins):
6+
# 1) $SOLEN_POLICY (set to /dev/null to disable policy)
7+
# 2) $HOME/.serverutils/policy.yaml
8+
# 3) /etc/solen/policy.yaml
9+
#
10+
# Behavior:
11+
# - If no policy file is present, all checks allow by default (return 0).
12+
# - If a list exists under allow.* for a given check, membership is required.
13+
# - deny.* lists override and force a deny when matched.
14+
15+
__SOLEN_POLICY_LOADED=0
16+
__SOLEN_POLICY_FILE=""
17+
declare -a _SOLEN_ALLOW_TOKENS
18+
declare -a _SOLEN_ALLOW_SERV_RESTART
19+
declare -a _SOLEN_ALLOW_PATHS_PRUNE
20+
declare -a _SOLEN_DENY_SERV_RESTART
21+
declare -a _SOLEN_DENY_PATHS_PRUNE
22+
23+
solen__policy_path() {
24+
local p=""
25+
if [[ -n "${SOLEN_POLICY:-}" ]]; then
26+
[[ "${SOLEN_POLICY}" == "/dev/null" ]] && echo "" && return 0
27+
[[ -r "${SOLEN_POLICY}" ]] && echo "${SOLEN_POLICY}" && return 0
28+
fi
29+
if [[ -r "${HOME}/.serverutils/policy.yaml" ]]; then
30+
echo "${HOME}/.serverutils/policy.yaml"; return 0
31+
fi
32+
if [[ -r "/etc/solen/policy.yaml" ]]; then
33+
echo "/etc/solen/policy.yaml"; return 0
34+
fi
35+
echo ""
36+
}
37+
38+
solen__arr_contains() { # arrname value
39+
local __arr_name="$1"; shift
40+
local needle="$1"; shift || true
41+
local v
42+
# shellcheck disable=SC1087,SC2128
43+
for v in ${!__arr_name[@]}; do :; done >/dev/null 2>&1 || true
44+
# iterate using indirect expansion
45+
local -n __arr_ref="${__arr_name}"
46+
for v in "${__arr_ref[@]}"; do
47+
[[ "$v" == "$needle" ]] && return 0
48+
done
49+
return 1
50+
}
51+
52+
solen__path_has_prefix() { # haystack_path candidate_prefix
53+
local path="$1"; local pref="$2"
54+
[[ -z "$path" || -z "$pref" ]] && return 1
55+
case "$path" in
56+
"$pref"|"$pref"/*) return 0 ;;
57+
esac
58+
return 1
59+
}
60+
61+
solen__read_inline_list() { # input like: key: ["a", "b", c]
62+
local line="$1"
63+
local inside="${line#*[}"
64+
inside="${inside%]*}"
65+
# split by comma
66+
local IFS=,
67+
read -r -a parts <<< "$inside"
68+
for it in "${parts[@]}"; do
69+
it="${it## }"; it="${it%% }"
70+
it="${it%\r}"
71+
it="${it%\n}"
72+
it="${it%\,}"
73+
it="${it#\"}"; it="${it%\"}"
74+
it="${it#\'}"; it="${it%\'}"
75+
[[ -n "$it" ]] && printf '%s\n' "$it"
76+
done
77+
}
78+
79+
solen__policy_load_awk() {
80+
local f="$1"
81+
local mode="" sub=""
82+
while IFS= read -r line; do
83+
# normalize tabs
84+
case "$line" in $'\t'*) line="${line//$'\t'/ }" ;; esac
85+
# state transitions
86+
if [[ "$line" =~ ^[[:space:]]*allow: ]]; then mode="allow"; sub=""; continue; fi
87+
if [[ "$line" =~ ^[[:space:]]*deny: ]]; then mode="deny"; sub=""; continue; fi
88+
if [[ "$line" =~ ^[[:space:]]*services: ]]; then sub="services"; continue; fi
89+
if [[ "$line" =~ ^[[:space:]]*paths: ]]; then sub="paths"; continue; fi
90+
if [[ "$line" =~ ^[[:space:]]*tokens: ]]; then
91+
if [[ "$line" =~ \[.*\] ]]; then
92+
while IFS= read -r item; do _SOLEN_ALLOW_TOKENS+=("$item"); done < <(solen__read_inline_list "$line")
93+
continue
94+
fi
95+
# read multi-line list that follows under allow: tokens:
96+
while IFS= read -r nxt; do
97+
[[ ! "$nxt" =~ ^[[:space:]]*-[[:space:]] ]] && { line="$nxt"; break; }
98+
nxt="${nxt#*- }"; nxt="${nxt## }"; nxt="${nxt%% }"; nxt="${nxt#\"}"; nxt="${nxt%\"}"; nxt="${nxt#\'}"; nxt="${nxt%\'}"
99+
[[ -n "$nxt" ]] && _SOLEN_ALLOW_TOKENS+=("$nxt")
100+
done
101+
continue
102+
fi
103+
# services.restart / services.enable
104+
if [[ "$mode" == "allow" && "$sub" == "services" && "$line" =~ ^[[:space:]]*restart: ]]; then
105+
if [[ "$line" =~ \[.*\] ]]; then
106+
while IFS= read -r item; do _SOLEN_ALLOW_SERV_RESTART+=("$item"); done < <(solen__read_inline_list "$line")
107+
else
108+
while IFS= read -r nxt; do
109+
[[ ! "$nxt" =~ ^[[:space:]]*-[[:space:]] ]] && { line="$nxt"; break; }
110+
nxt="${nxt#*- }"; nxt="${nxt## }"; nxt="${nxt%% }"; nxt="${nxt#\"}"; nxt="${nxt%\"}"; nxt="${nxt#\'}"; nxt="${nxt%\'}"
111+
[[ -n "$nxt" ]] && _SOLEN_ALLOW_SERV_RESTART+=("$nxt")
112+
done
113+
fi
114+
continue
115+
fi
116+
# paths.prune
117+
if [[ "$sub" == "paths" && "$line" =~ ^[[:space:]]*prune: ]]; then
118+
local arrname="_SOLEN_ALLOW_PATHS_PRUNE"
119+
if [[ "$mode" == "deny" ]]; then arrname="_SOLEN_DENY_PATHS_PRUNE"; fi
120+
if [[ "$line" =~ \[.*\] ]]; then
121+
while IFS= read -r item; do eval "$arrname+=(\"\$item\")"; done < <(solen__read_inline_list "$line")
122+
else
123+
while IFS= read -r nxt; do
124+
[[ ! "$nxt" =~ ^[[:space:]]*-[[:space:]] ]] && { line="$nxt"; break; }
125+
nxt="${nxt#*- }"; nxt="${nxt## }"; nxt="${nxt%% }"; nxt="${nxt#\"}"; nxt="${nxt%\"}"; nxt="${nxt#\'}"; nxt="${nxt%\'}"
126+
[[ -n "$nxt" ]] && eval "$arrname+=(\"\$nxt\")"
127+
done
128+
fi
129+
continue
130+
fi
131+
# deny.services.restart
132+
if [[ "$mode" == "deny" && "$sub" == "services" && "$line" =~ ^[[:space:]]*restart: ]]; then
133+
if [[ "$line" =~ \[.*\] ]]; then
134+
while IFS= read -r item; do _SOLEN_DENY_SERV_RESTART+=("$item"); done < <(solen__read_inline_list "$line")
135+
else
136+
while IFS= read -r nxt; do
137+
[[ ! "$nxt" =~ ^[[:space:]]*-[[:space:]] ]] && { line="$nxt"; break; }
138+
nxt="${nxt#*- }"; nxt="${nxt## }"; nxt="${nxt%% }"; nxt="${nxt#\"}"; nxt="${nxt%\"}"; nxt="${nxt#\'}"; nxt="${nxt%\'}"
139+
[[ -n "$nxt" ]] && _SOLEN_DENY_SERV_RESTART+=("$nxt")
140+
done
141+
fi
142+
continue
143+
fi
144+
done < "$f"
145+
}
146+
147+
solen__policy_load() {
148+
[[ $__SOLEN_POLICY_LOADED -eq 1 ]] && return 0
149+
__SOLEN_POLICY_FILE="$(solen__policy_path)"
150+
if [[ -z "$__SOLEN_POLICY_FILE" || ! -r "$__SOLEN_POLICY_FILE" ]]; then
151+
__SOLEN_POLICY_LOADED=1; return 0
152+
fi
153+
# Clear arrays
154+
_SOLEN_ALLOW_TOKENS=()
155+
_SOLEN_ALLOW_SERV_RESTART=()
156+
_SOLEN_ALLOW_PATHS_PRUNE=()
157+
_SOLEN_DENY_SERV_RESTART=()
158+
_SOLEN_DENY_PATHS_PRUNE=()
159+
if command -v yq >/dev/null 2>&1; then
160+
# allow tokens
161+
while IFS= read -r t; do [[ -n "$t" && "$t" != "null" ]] && _SOLEN_ALLOW_TOKENS+=("$t"); done < <(yq -r '.allow.tokens[]? // empty' "$__SOLEN_POLICY_FILE" 2>/dev/null)
162+
# services.restart allow/deny
163+
while IFS= read -r s; do [[ -n "$s" && "$s" != "null" ]] && _SOLEN_ALLOW_SERV_RESTART+=("$s"); done < <(yq -r '.allow.services.restart[]? // empty' "$__SOLEN_POLICY_FILE" 2>/dev/null)
164+
while IFS= read -r s; do [[ -n "$s" && "$s" != "null" ]] && _SOLEN_DENY_SERV_RESTART+=("$s"); done < <(yq -r '.deny.services.restart[]? // empty' "$__SOLEN_POLICY_FILE" 2>/dev/null)
165+
# paths.prune allow/deny
166+
while IFS= read -r p; do [[ -n "$p" && "$p" != "null" ]] && _SOLEN_ALLOW_PATHS_PRUNE+=("$p"); done < <(yq -r '.allow.paths.prune[]? // empty' "$__SOLEN_POLICY_FILE" 2>/dev/null)
167+
while IFS= read -r p; do [[ -n "$p" && "$p" != "null" ]] && _SOLEN_DENY_PATHS_PRUNE+=("$p"); done < <(yq -r '.deny.paths.prune[]? // empty' "$__SOLEN_POLICY_FILE" 2>/dev/null)
168+
else
169+
solen__policy_load_awk "$__SOLEN_POLICY_FILE"
170+
fi
171+
__SOLEN_POLICY_LOADED=1
172+
}
173+
174+
solen_policy_allows_token() { # token
175+
local token="$1"
176+
solen__policy_load
177+
# No policy => allow
178+
if [[ -z "$__SOLEN_POLICY_FILE" ]]; then return 0; fi
179+
# If allow list is empty or missing, allow
180+
local count=${#_SOLEN_ALLOW_TOKENS[@]}
181+
if (( count == 0 )); then return 0; fi
182+
solen__arr_contains _SOLEN_ALLOW_TOKENS "$token"
183+
}
184+
185+
solen_policy_allows_service_restart() { # service
186+
local svc="$1"
187+
solen__policy_load
188+
if [[ -z "$__SOLEN_POLICY_FILE" ]]; then return 0; fi
189+
# deny overrides
190+
if solen__arr_contains _SOLEN_DENY_SERV_RESTART "$svc"; then return 1; fi
191+
local count=${#_SOLEN_ALLOW_SERV_RESTART[@]}
192+
if (( count == 0 )); then return 0; fi
193+
solen__arr_contains _SOLEN_ALLOW_SERV_RESTART "$svc"
194+
}
195+
196+
solen_policy_allows_prune_path() { # path
197+
local path="$1"
198+
solen__policy_load
199+
if [[ -z "$__SOLEN_POLICY_FILE" ]]; then return 0; fi
200+
# deny overrides
201+
local p
202+
for p in "${_SOLEN_DENY_PATHS_PRUNE[@]}"; do
203+
if solen__path_has_prefix "$path" "$p"; then return 1; fi
204+
done
205+
local count=${#_SOLEN_ALLOW_PATHS_PRUNE[@]}
206+
if (( count == 0 )); then return 0; fi
207+
for p in "${_SOLEN_ALLOW_PATHS_PRUNE[@]}"; do
208+
if solen__path_has_prefix "$path" "$p"; then return 0; fi
209+
done
210+
return 1
211+
}
212+

Scripts/lib/solen.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,13 @@ solen_json_record_full() {
6969
local status="$1" summary="$2" details_fragment="$3"
7070
solen_json_record "$status" "$summary" "" "$details_fragment"
7171
}
72+
73+
# Try to source policy helpers if available; otherwise permissive fallbacks
74+
__SOLEN_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
75+
if [ -f "${__SOLEN_LIB_DIR}/policy.sh" ]; then
76+
. "${__SOLEN_LIB_DIR}/policy.sh"
77+
else
78+
solen_policy_allows_token() { return 0; }
79+
solen_policy_allows_service_restart() { return 0; }
80+
solen_policy_allows_prune_path() { return 0; }
81+
fi

0 commit comments

Comments
 (0)