diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go index eecfee1..b968c87 100644 --- a/cmd/bj-installer/main.go +++ b/cmd/bj-installer/main.go @@ -169,6 +169,14 @@ func (in *installer) run() error { todo = append(todo, s) } } + if os.Getenv("BJ_SCREEN") != "" { // экран установки (bj-firstboot-screen): весь план шагов заранее + var titles []string + for _, s := range todo { + titles = append(titles, s.title) + } + fmt.Fprintf(in.out.w, "@@plan %s\n", strings.Join(titles, "|")) + in.out.flush() + } for i, s := range todo { in.out.step(i+1, len(todo), s.title) detail, err := s.run() diff --git a/deploy/iso/bj-firstboot b/deploy/iso/bj-firstboot index b19e3cd..358aa06 100755 --- a/deploy/iso/bj-firstboot +++ b/deploy/iso/bj-firstboot @@ -9,16 +9,24 @@ SERVER=https://lic.zetit.ru CHANNEL=stable BUNDLE=/var/lib/bj/bundle [ -s /etc/bj/channel ] && CHANNEL=$(tr -d ' \n' < /etc/bj/channel) -finish() { +# установка прошла: отметка и выключение первой загрузки (приветственный экран — после Enter, иначе он +# сотрёт с экрана итог и пароли) +finish_quiet() { mkdir -p "$(dirname "$MARK")" && date -Iseconds > "$MARK" systemctl disable bj-firstboot.service >/dev/null 2>&1 - [ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome } +finish_screen() { [ -x /usr/local/sbin/bj-welcome ] && /usr/local/sbin/bj-welcome; } urls() { ip -4 -o addr show scope global 2>/dev/null | awk '{split($4,a,"/"); printf " https://%s:8443%s\n", a[1], "'"$1"'"}'; } # Пока идут вопросы, сообщения загрузки на консоль не выводятся (ядро — только аварийные, # systemd — без строк «[ OK ] Started …»), иначе они перемешиваются с текстом на экране. quiet_console() { dmesg -n 1 2>/dev/null; kill -s RTMIN+21 1 2>/dev/null; sleep 2; } quiet_console +# шрифт консоли установщика (кириллица, рамки, ✓ ▶ █): его кладёт образ; нет — экраны рисуются простыми символами +FONT=/usr/local/share/bj/console.psf +if [ -f "$FONT" ] && setfont "$FONT" 2>/dev/null; then export BJ_SCREEN_FONT=1; fi +if [ "${BJ_SCREEN_FONT:-}" = 1 ]; then BOX_T='╔══'; BOX_B='╚══'; BOX_H='═'; else BOX_T='+=='; BOX_B='+=='; BOX_H='='; fi +LOG=/var/log/bj/firstboot-install.log +SCREEN=/usr/local/sbin/bj-firstboot-screen ACC_DONE=/var/lib/bj/.bj/os-accounts-set ACCOUNTS="" genpw() { local p=""; while [ ${#p} -lt 12 ]; do p="$p$(head -c 48 /dev/urandom | tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789')"; done; printf '%s-%s-%s' "${p:0:4}" "${p:4:4}" "${p:8:4}"; } @@ -42,19 +50,48 @@ setup_accounts() { printf ' root_bj — администратор сервера (команды через sudo)\n user_bj — обычный вход (журналы, без прав администратора)\n' printf ' Работа с bj — в браузере; эти учётные записи нужны для обслуживания ОС.\n\n' printf ' Шаг 1 из 2 — пароли. Для каждой учётной записи предложен пароль:\n' - printf ' • Enter — оставить предложенный (он будет показан ещё раз в конце, запишите его);\n' + printf ' • Enter — оставить предложенный (на следующем экране его нужно будет записать или сфотографировать);\n' printf ' • или наберите свой (не короче 10 символов, при вводе не отображается) и Enter.\n\n' PA=$(askpw root_bj "$(genpw)"); PU=$(askpw user_bj "$(genpw)") printf 'root_bj:%s\nuser_bj:%s\n' "$PA" "$PU" | chpasswd mkdir -p "$(dirname "$ACC_DONE")" && date -Iseconds > "$ACC_DONE" - ACCOUNTS=$(printf ' root_bj (администратор, sudo) пароль: %s\n user_bj (обычный вход) пароль: %s\n' "$PA" "$PU") + ACCOUNTS=$(printf ' root_bj (администратор, sudo) пароль: %s\n user_bj (обычный вход) пароль: %s\n' "$PA" "$PU") unset PA PU + clear + printf '\n Bridge&Join: учётные записи сервера\n' + show_accounts + confirm_accounts } +# пароли учётных записей — крупно, с просьбой сфотографировать или записать: нигде не сохраняются show_accounts() { - [ -n "$ACCOUNTS" ] || return - printf '\n ┌── ЗАПИШИТЕ: учётные записи сервера ──────────────────────────┐\n%s\n' "$ACCOUNTS" - printf ' └── пароли нигде не сохранены и больше не будут показаны ──────┘\n' - printf ' Сменить пароль: passwd (под своей учётной записью)\n\n' + [ -n "$ACCOUNTS" ] || return 0 + printf '\n %s СФОТОГРАФИРУЙТЕ ИЛИ ЗАПИШИТЕ ЭТИ ПАРОЛИ %s\n\n%s\n\n' "$BOX_T" "$(printf '%*s' 24 '' | tr ' ' "$BOX_H")" "$ACCOUNTS" + printf ' %s они показаны только здесь и нигде не сохраняются %s\n' "$BOX_B" "$(printf '%*s' 14 '' | tr ' ' "$BOX_H")" + printf ' Вход в консоль — для обслуживания сервера; работа с bj — в браузере. Сменить пароль: passwd\n\n' +} +# пока не нажат Enter — экран не меняется (без таймера: пароли не должны пропасть, пока их не записали) +confirm_accounts() { + [ -n "$ACCOUNTS" ] || return 0 + read -r -p " Записали или сфотографировали пароли? Нажмите Enter. " _ < /dev/tty || true +} +# установка bj: экран хода (bj-firstboot-screen), весь вывод — в журнал $LOG; не вышло — ещё одна попытка: +# на первой загрузке что-то могло не успеть запуститься, пройденные шаги повторяются безопасно +install_bj() { + mkdir -p /var/log/bj + for try in 1 2; do + if command -v python3 >/dev/null 2>&1 && [ -x "$SCREEN" ]; then + python3 "$SCREEN" --log "$LOG" -- /usr/local/sbin/bj-installer install "$@" && return 0 + else + /usr/local/sbin/bj-installer install "$@" 2>&1 | tee -a "$LOG" + [ "${PIPESTATUS[0]}" = 0 ] && return 0 + fi + if [ "$try" = 1 ]; then + printf '\n Не получилось с первого раза — через 10 секунд попробую ещё раз.\n' + sleep 10 + fi + done + printf '\n Подробный журнал установки: %s\n' "$LOG" + return 1 } setup_accounts # постоянный адрес: DHCP может выдать серверу другой адрес, и bj «потеряется» (только Ubuntu/netplan) @@ -88,26 +125,30 @@ while true; do CODE=$(printf '%s' "${CODE:-}" | tr -d ' ' | tr '[:lower:]' '[:upper:]') if [ -z "$CODE" ]; then if [ -f "$BUNDLE/manifest.json" ]; then - if /usr/local/sbin/bj-installer install --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then - finish # адрес, код и подсказку про ключ продукта уже вывел bj-installer + if install_bj --bundle "$BUNDLE" --server "$SERVER" --channel "$CHANNEL"; then + finish_quiet # адрес и код первичной настройки — на экране установки выше show_accounts - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ || true + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + finish_screen exit 0 fi show_accounts - printf '\n Установка с образа не завершилась. Подробности выше; повторить:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" - sleep 5; exit 0 + printf ' Установка bj не завершилась. Она повторится при следующей загрузке, или сейчас вручную:\n sudo bj-installer install --bundle %s\n\n' "$BUNDLE" + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + exit 0 fi show_accounts printf '\n Установка bj отложена. Вопрос повторится при следующей загрузке; вручную:\n sudo bj-installer install --code КЛЮЧ-ПРОДУКТА\n\n' - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ || true + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true exit 0 fi - if /usr/local/sbin/bj-installer install --code "$CODE" --server "$SERVER" --channel "$CHANNEL"; then - finish + if install_bj --code "$CODE" --server "$SERVER" --channel "$CHANNEL"; then + finish_quiet show_accounts - read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ || true + read -r -p " Нажмите Enter, чтобы перейти к экрану входа. " _ < /dev/tty || true + finish_screen exit 0 fi - printf '\n Установка не завершилась. Проверьте ключ продукта и сеть и попробуйте ещё раз.\n\n' + show_accounts + printf ' Установка не завершилась. Проверьте ключ продукта и сеть и попробуйте ещё раз.\n\n' done diff --git a/deploy/iso/bj-firstboot-screen b/deploy/iso/bj-firstboot-screen new file mode 100755 index 0000000..45239af --- /dev/null +++ b/deploy/iso/bj-firstboot-screen @@ -0,0 +1,248 @@ +#!/usr/bin/python3 +# Экран установки bj при первой загрузке сервера (bj-firstboot) — в том же оформлении, что экран установки +# системы (bj-install-progress): рамка Bridge&Join, шаги установщика с отметками, полоса хода. Запускает +# установщик, весь его вывод пишет в журнал (--log) и по этому выводу рисует экран; код возврата — как у +# установщика. Последний кадр остаётся на экране (сверху) — под ним bj-firstboot выводит учётные записи. +# +# bj-firstboot-screen --log /var/log/bj/firstboot-install.log -- bj-installer install --bundle … +# +# Установщик с BJ_SCREEN=1 печатает в начале «@@plan шаг|шаг|…», дальше — «[i/n] Шаг готово|ошибка», +# строки-подробности с отступом, в конце «bj установлен» с адресом и кодом или «Установка не завершена.». +# Знаки ✓ ▶ █ ░ ═ — только со шрифтом консоли установщика (BJ_SCREEN_FONT=1), иначе простые символы. +# Проверка без установки: bj-firstboot-screen --demo <файл вывода установщика> [--html кадр.html] + +import os +import re +import select +import shutil +import subprocess +import sys +import time + +FANCY = os.environ.get("BJ_SCREEN_FONT") == "1" +G = dict(ok="✓", run="▶", wait="·", err="✗", full="█", empty="░", h="═", v="║", tl="╔", tr="╗", bl="╚", br="╝") if FANCY else \ + dict(ok="+", run=">", wait=".", err="x", full="#", empty="-", h="=", v="|", tl="+", tr="+", bl="+", br="+") +RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m" +GREEN, CYAN, RED, WHITE, GREY, YELLOW = "\033[32m", "\033[36m", "\033[31m", "\033[97m", "\033[90m", "\033[33m" +STEP_RE = re.compile(r"\[(\d+)/(\d+)\]\s+(.*?)\s*(готово|ошибка)?\s*$") + + +class State: + def __init__(self): + self.plan, self.status, self.cur = [], {}, 0 # status: номер шага → ok|err + self.cur_started = time.time() + self.started = time.time() + self.caption = "" + self.done = False + self.error = "" + self.urls, self.code = [], "" + self.buf = "" + + def feed(self, text): + self.buf += text + while "\n" in self.buf: + line, self.buf = self.buf.split("\n", 1) + self.line(line) + m = STEP_RE.search(self.buf) # строка шага без конца — шаг идёт + if m: + self.start_step(int(m.group(1)), int(m.group(2)), m.group(3)) + + def start_step(self, i, n, title): + if not self.plan: + self.plan = [""] * n + if i <= len(self.plan) and not self.plan[i - 1]: + self.plan[i - 1] = title + if i != self.cur: + self.cur, self.cur_started, self.caption = i, time.time(), "" + + def line(self, line): + s = line.strip() + if s.startswith("@@plan "): + self.plan = s[7:].split("|") + return + m = STEP_RE.search(line) + if m: + i = int(m.group(1)) + self.start_step(i, int(m.group(2)), m.group(3)) + if m.group(4) == "готово": + self.status[i] = "ok" + elif m.group(4) == "ошибка": + self.status[i] = "err" + return + if s in ("готово", "ошибка") and self.cur: # отметка шага пришла отдельной строкой + self.status[self.cur] = "ok" if s == "готово" else "err" + return + if s.startswith("Установка не завершена."): + self.error = s[len("Установка не завершена."):].strip() + elif s == "bj установлен": + self.done = True + elif "/first-run" in s and s.startswith("http"): + self.urls.append(s) + elif s.startswith("Одноразовый код первичной настройки:"): + self.code = s.split(":", 1)[1].strip() + elif line.startswith(" ") and s and self.cur and not self.done: + self.caption = s # подробность последнего шага + + def percent(self): + n = max(1, len(self.plan)) + k = sum(1 for v in self.status.values() if v == "ok") + if self.cur and self.cur not in self.status: # текущий шаг — доля по времени, не больше 90 % + k += min(0.9, (time.time() - self.cur_started) / 60.0) + return min(100, int(100 * k / n)) + + +def mmss(sec): + sec = int(max(0, sec)) + return "%d:%02d" % (sec // 60, sec % 60) + + +def frame(st, cols, rows, final=False): + W = 70 + inner = W - 2 + lines = [] + + def row(parts=()): + parts, text = list(parts), "" + for i, (t, c) in enumerate(parts): + if len(text) + len(t) > inner: + parts = parts[:i] + [(t[: inner - len(text)], c)] + text += parts[-1][0] + break + text += t + lines.append([(G["v"], GREY)] + parts + [(" " * (inner - len(text)), ""), (G["v"], GREY)]) + + lines.append([(G["tl"] + G["h"] * inner + G["tr"], GREY)]) + row() + row([(" Bridge&Join", BOLD + WHITE)]) + row([(" шлюз, объединяющий системы финансовой организации", DIM)]) + row() + row([(" Установка bj на сервер", BOLD)]) + row() + for i, title in enumerate(st.plan, 1): + title = title or "…" + stv = st.status.get(i) + if stv == "ok": + row([(" " + G["ok"] + " ", GREEN), (title, "")]) + elif stv == "err": + row([(" " + G["err"] + " ", RED), (title, BOLD + RED)]) + elif i == st.cur and not final: + t = mmss(time.time() - st.cur_started) + row([(" " + G["run"] + " ", CYAN), (title, BOLD + CYAN), (" " * max(1, inner - 9 - len(title) - len(t)), ""), (t, DIM)]) + else: + row([(" " + G["wait"] + " ", GREY), (title, GREY)]) + row() + if st.error or (final and not st.done): + row([(" " + G["err"] + " Установка не завершена.", BOLD + RED)]) + for chunk in wrap(st.error or "подробности — в журнале установки", inner - 6): + row([(" " + chunk, "")]) + elif st.done: + row([(" " + G["ok"] + " bj установлен", BOLD + GREEN)]) + row() + row([(" Откройте в браузере на своём компьютере:", "")]) + for u in st.urls[:3]: + row([(" " + u, BOLD + WHITE)]) + if st.code: + row([(" Одноразовый код первичной настройки: ", ""), (st.code, BOLD + YELLOW)]) + row() + row([(" Браузер предупредит о сертификате — это сертификат самого", DIM)]) + row([(" сервера bj: подтвердите исключение.", DIM)]) + else: + pct = st.percent() + bar_w = 48 + full = int(bar_w * pct / 100) + row([(" ", ""), (G["full"] * full, CYAN), (G["empty"] * (bar_w - full), GREY), (" %3d %%" % pct, BOLD)]) + row([(" " + st.caption, DIM)] if st.caption else []) + row() + row([(" Не выключайте сервер: обычно это занимает несколько минут.", "")]) + row() + lines.append([(G["bl"] + G["h"] * inner + G["br"], GREY)]) + foot = "прошло " + mmss(time.time() - st.started) + lines.append([(" " * max(0, (W - len(foot)) // 2) + foot, GREY)]) + left = max(0, (cols - W) // 2) + top = 1 if final else max(0, (rows - len(lines)) // 2) # итог — сверху: ниже bj-firstboot выведет учётные записи + return [[("", "")]] * top + [[(" " * left, "")] + ln for ln in lines] + + +def wrap(text, width): + out, cur = [], "" + for w in text.split(): + if cur and len(cur) + 1 + len(w) > width: + out.append(cur) + cur = w + else: + cur = (cur + " " + w).strip() + if cur: + out.append(cur) + return out[:4] or [""] + + +def to_ansi(fr): + return "\033[H\033[2J" + "".join("".join((c + t + RESET) if c else t for t, c in ln) + "\n" for ln in fr) + + +def to_html(fr): + css = {GREEN: "#4e9a06", CYAN: "#06989a", RED: "#cc0000", GREY: "#888", DIM: "#9a9a9a", WHITE: "#fff", YELLOW: "#c4a000"} + rows = [] + for ln in fr: + h = "" + for t, c in ln: + t = t.replace("&", "&").replace("<", "<") + if not c: + h += t + continue + color = next((css[k] for k in (GREEN, CYAN, RED, GREY, WHITE, YELLOW, DIM) if k in c), "#ddd") + h += '%s' % (color, "700" if BOLD in c else "400", t) + rows.append(h) + return ('
' + "\n".join(rows) + "
") + + +def main(): + a = sys.argv[1:] + if len(a) >= 2 and a[0] == "--demo": + st = State() + st.feed(open(a[1], encoding="utf-8").read()) + st.started -= 95 + st.cur_started -= 24 + fr = frame(st, 100, 40, final=st.done or bool(st.error)) + if len(a) >= 4 and a[2] == "--html": + open(a[3], "w", encoding="utf-8").write(to_html(fr)) + else: + sys.stdout.write(to_ansi(fr)) + return 0 + if len(a) < 4 or a[0] != "--log" or a[2] != "--": + sys.stderr.write("bj-firstboot-screen --log ФАЙЛ -- команда …\n") + return 2 + log_path, cmd = a[1], a[3:] + os.makedirs(os.path.dirname(log_path), exist_ok=True) + log = open(log_path, "ab", buffering=0) + log.write(("\n==== %s: %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), " ".join(cmd))).encode()) + env = dict(os.environ, BJ_SCREEN="1", NO_COLOR="1") + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env, bufsize=0) + st = State() + sys.stdout.write("\033[?25l") + try: + while True: + r, _, _ = select.select([p.stdout], [], [], 1.0) + if r: + chunk = os.read(p.stdout.fileno(), 65536) + if not chunk: + break + log.write(chunk) + st.feed(chunk.decode("utf-8", "replace")) + cols, rows = shutil.get_terminal_size((100, 40)) + sys.stdout.write(to_ansi(frame(st, cols, rows))) + sys.stdout.flush() + code = p.wait() + if code != 0 and not st.error: + st.error = "установщик завершился с кодом %d" % code + cols, rows = shutil.get_terminal_size((100, 40)) + sys.stdout.write(to_ansi(frame(st, cols, rows, final=True))) + return code + finally: + sys.stdout.write("\033[?25h") + sys.stdout.flush() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/iso/build-iso-ubuntu.sh b/deploy/iso/build-iso-ubuntu.sh index bd0ec42..5d24b5b 100755 --- a/deploy/iso/build-iso-ubuntu.sh +++ b/deploy/iso/build-iso-ubuntu.sh @@ -28,6 +28,7 @@ mkdir -p "$WORK/bj/bundle" "$WORK/bj/validata" "$WORK/nocloud" "$WORK/grub" install -m 0755 "$INSTALLER" "$WORK/bj/bj-installer" install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot" +install -m 0755 "$HERE/bj-firstboot-screen" "$WORK/bj/bj-firstboot-screen" # экран установки bj при первой загрузке install -m 0755 "$HERE/bj-install-progress" "$HERE/bj-install-progress-start" "$WORK/bj/" # экран установки на консоли cp "$HERE/bj-firstboot.service" "$WORK/bj/" install -m 0755 "$HERE/../linux/sbin/bj-welcome" "$WORK/bj/bj-welcome" diff --git a/deploy/iso/build-iso.sh b/deploy/iso/build-iso.sh index 6f6cadf..c60879c 100755 --- a/deploy/iso/build-iso.sh +++ b/deploy/iso/build-iso.sh @@ -24,6 +24,7 @@ mkdir -p "$WORK/bj" "$WORK/preseed" "$WORK/isolinux" "$WORK/grub" cp "$HERE/preseed.cfg" "$WORK/preseed/bj.cfg" install -m 0755 "$INSTALLER" "$WORK/bj/bj-installer" install -m 0755 "$HERE/bj-firstboot" "$WORK/bj/bj-firstboot" +install -m 0755 "$HERE/bj-firstboot-screen" "$WORK/bj/bj-firstboot-screen" # экран установки bj при первой загрузке cp "$HERE/bj-firstboot.service" "$WORK/bj/bj-firstboot.service" echo "$CHANNEL" > "$WORK/bj/channel" echo "$VERSION" > "$WORK/bj/version" diff --git a/deploy/iso/preseed.cfg b/deploy/iso/preseed.cfg index 0a5693c..0f9695c 100644 --- a/deploy/iso/preseed.cfg +++ b/deploy/iso/preseed.cfg @@ -72,6 +72,7 @@ d-i grub-installer/bootdev string default d-i preseed/late_command string \ install -m 0755 /cdrom/bj/bj-installer /target/usr/local/sbin/bj-installer; \ install -m 0755 /cdrom/bj/bj-firstboot /target/usr/local/sbin/bj-firstboot; \ + if [ -f /cdrom/bj/bj-firstboot-screen ]; then install -m 0755 /cdrom/bj/bj-firstboot-screen /target/usr/local/sbin/bj-firstboot-screen; fi; \ install -m 0644 /cdrom/bj/bj-firstboot.service /target/etc/systemd/system/bj-firstboot.service; \ mkdir -p /target/etc/bj; \ if [ -f /cdrom/bj/channel ]; then install -m 0644 /cdrom/bj/channel /target/etc/bj/channel; fi; \ diff --git a/deploy/iso/ubuntu/user-data.tmpl b/deploy/iso/ubuntu/user-data.tmpl index afebbcc..9b62eb6 100644 --- a/deploy/iso/ubuntu/user-data.tmpl +++ b/deploy/iso/ubuntu/user-data.tmpl @@ -41,7 +41,9 @@ autoinstall: - cp -r /cdrom/bj/validata /target/var/lib/bj/validata - sh -c 'if [ -d /cdrom/bj/debs ]; then cp -r /cdrom/bj/debs /target/var/lib/bj/debs; fi' - sh -c 'if [ -d /cdrom/bj/igate ]; then mkdir -p /target/var/lib/bj/igate-dist && cp /cdrom/bj/igate/igate-*-amd64.zip /target/var/lib/bj/igate-dist/; fi' - - install -m 0755 /cdrom/bj/bj-installer /cdrom/bj/bj-firstboot /cdrom/bj/bj-welcome /cdrom/bj/bj-network /target/usr/local/sbin/ + - install -m 0755 /cdrom/bj/bj-installer /cdrom/bj/bj-firstboot /cdrom/bj/bj-firstboot-screen /cdrom/bj/bj-welcome /cdrom/bj/bj-network /target/usr/local/sbin/ + # шрифт консоли установщика (кириллица, рамки, ✓ ▶ █) — для экранов первой загрузки + - sh -c 'mkdir -p /target/usr/local/share/bj && cp /snap/subiquity/current/subiquity.psf /target/usr/local/share/bj/console.psf || true' - install -m 0644 /cdrom/bj/bj-firstboot.service /cdrom/bj/bj-welcome.service /cdrom/bj/bj-welcome.timer /target/etc/systemd/system/ - install -m 0644 /cdrom/bj/channel /target/etc/bj/channel - curtin in-target --target=/target -- systemctl enable bj-firstboot.service bj-welcome.service bj-welcome.timer