improvements for first-time setup
This commit is contained in:
parent
e59d85cf57
commit
979d27ec7a
16 changed files with 901 additions and 33 deletions
|
|
@ -140,8 +140,13 @@ TELESRV_ADMIN_SESSION_KEY=
|
||||||
# the admin panel entirely; set to a loopback address (127.0.0.1:...) to
|
# the admin panel entirely; set to a loopback address (127.0.0.1:...) to
|
||||||
# enable it without exposing it outside this machine.
|
# enable it without exposing it outside this machine.
|
||||||
TELESRV_ADMIN_API_ADDR=
|
TELESRV_ADMIN_API_ADDR=
|
||||||
# Address the admin panel's own web UI listens on.
|
# Address the admin panel's own web UI listens on. 0.0.0.0 so a fresh
|
||||||
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
|
# install is reachable right away from wherever you're setting it up from --
|
||||||
|
# a VPS you're provisioning from across the world included -- without an SSH
|
||||||
|
# tunnel just to see the first-run wizard. Narrow it to a loopback or LAN
|
||||||
|
# address once you're done if you'd rather it not be open to the internet;
|
||||||
|
# the login itself still needs the password (or token) below either way.
|
||||||
|
TELESRV_ADMIN_UI_ADDR=0.0.0.0:2600
|
||||||
# Permissions granted to an Admin UI session that logged in with
|
# Permissions granted to an Admin UI session that logged in with
|
||||||
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
|
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
|
||||||
# permission and is the default, so enabling RBAC never locks an operator out of
|
# permission and is the default, so enabling RBAC never locks an operator out of
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"telesrv/internal/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateAdminPassword(t *testing.T) {
|
func TestValidateAdminPassword(t *testing.T) {
|
||||||
|
|
@ -203,6 +207,57 @@ func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The break-glass password quickstart generates for the very first login
|
||||||
|
// must stop authenticating once the first-run wizard is done, but a
|
||||||
|
// password an operator actually chose -- even one that happens to still be
|
||||||
|
// sitting in .env from before the wizard finished -- must never be
|
||||||
|
// affected by that. This is the actual integration point between
|
||||||
|
// validSecret and identity.Store; the package's own tests cover
|
||||||
|
// SetupPending/TemporaryPasswordMatches in isolation.
|
||||||
|
func TestValidSecretRetiresOnlyTheGeneratedPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store := identity.NewStore(dir)
|
||||||
|
s := &server{cfg: uiConfig{Password: "generated-once", Permissions: []string{permissionAll}}, identity: store}
|
||||||
|
|
||||||
|
// No marker written at all yet (identity.Store's zero state) -- the
|
||||||
|
// password behaves like an ordinary one an operator set.
|
||||||
|
if !s.validSecret("generated-once") {
|
||||||
|
t.Fatal("password should authenticate before any wizard marker exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap-style: the setup-pending marker plus the matching
|
||||||
|
// temporary-password marker, exactly as tui-panel/server-panel.py's
|
||||||
|
// bootstrap_env() writes them on a fresh install.
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".setup_pending"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".admin_password_temporary"), []byte("generated-once"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.validSecret("generated-once") {
|
||||||
|
t.Fatal("the generated password must keep working while the wizard is still pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard finishes: MarkSetupComplete removes both markers.
|
||||||
|
if err := store.MarkSetupComplete(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.validSecret("generated-once") {
|
||||||
|
t.Fatal("the generated password must stop authenticating once setup is complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator-chosen password behaves normally regardless: setting a
|
||||||
|
// new .env value (this test's stand-in for that) authenticates whether
|
||||||
|
// or not a wizard ever ran, because it never matches either marker.
|
||||||
|
s.cfg.Password = "an-operator-actually-chose-this"
|
||||||
|
if !s.validSecret("an-operator-actually-chose-this") {
|
||||||
|
t.Fatal("an operator-chosen password must authenticate after setup completion, same as always")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A session for a named account must not be trusted on the strength of its
|
// A session for a named account must not be trusted on the strength of its
|
||||||
// signature alone: the account's rights are re-read per request, and a nil read
|
// signature alone: the account's rights are re-read per request, and a nil read
|
||||||
// store has to fail closed rather than fall back to the claims.
|
// store has to fail closed rather than fall back to the claims.
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
|
mux.Handle("POST /api/actions/set-login-code-message-template", s.serverManage(s.handleSetLoginCodeMessageTemplateAPI))
|
||||||
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
|
mux.Handle("POST /api/actions/upload-server-icon", s.serverManage(s.handleUploadServerIconAPI))
|
||||||
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
|
mux.Handle("POST /api/actions/remove-server-icon", s.serverManage(s.handleRemoveServerIconAPI))
|
||||||
|
mux.Handle("POST /api/actions/complete-setup", s.serverManage(s.handleCompleteSetupAPI))
|
||||||
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
mux.Handle("GET /api/server/env", s.serverManage(s.handleServerEnvAPI))
|
||||||
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
mux.Handle("POST /api/actions/update-server-env", s.serverManage(s.handleUpdateServerEnvAPI))
|
||||||
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
|
mux.Handle("GET /api/server/status", s.serverManage(s.handleServerStatusAPI))
|
||||||
|
|
@ -314,6 +315,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (s *server) validSecret(secret string) bool {
|
func (s *server) validSecret(secret string) bool {
|
||||||
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
|
if s.cfg.Password != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Password)) == 1 {
|
||||||
|
// The password quickstart auto-generates for the very first login
|
||||||
|
// (see identity.Store.TemporaryPasswordMatches) is only good until
|
||||||
|
// the first-run wizard finishes -- one login's worth of "how do I
|
||||||
|
// even get in", not a credential anyone actually chose to keep
|
||||||
|
// around. A password an operator set on purpose, whether by saving
|
||||||
|
// one from Server Settings or editing .env by hand, never matches
|
||||||
|
// the stored generated value, so this never touches it.
|
||||||
|
if s.identity.TemporaryPasswordMatches(s.cfg.Password) && !s.identity.SetupPending() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
|
if s.cfg.Token != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(s.cfg.Token)) == 1 {
|
||||||
|
|
@ -352,6 +363,10 @@ func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||||
"actor": actorFromContext(r.Context()),
|
"actor": actorFromContext(r.Context()),
|
||||||
"permissions": permissionsFromContext(r.Context()).List(),
|
"permissions": permissionsFromContext(r.Context()).List(),
|
||||||
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,
|
||||||
|
// setup_completed gates the first-run wizard -- see
|
||||||
|
// identity.Store.SetupPending's doc comment for why this reads a
|
||||||
|
// sentinel file rather than anything in identity.json itself.
|
||||||
|
"setup_completed": !s.identity.SetupPending(),
|
||||||
// boot_id is random per process start (see main.go) -- Server
|
// boot_id is random per process start (see main.go) -- Server
|
||||||
// Settings' Restart/Update flow polls this after triggering an
|
// Settings' Restart/Update flow polls this after triggering an
|
||||||
// action and reloads the page once it changes, which is how it
|
// action and reloads the page once it changes, which is how it
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,35 @@ func (s *server) handleRemoveServerIconAPI(w http.ResponseWriter, r *http.Reques
|
||||||
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.remove_icon", err, "server icon removed", nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- first-run setup wizard ----------------------------------------------
|
||||||
|
|
||||||
|
type completeSetupAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCompleteSetupAPI is the wizard's last step: removes the
|
||||||
|
// identity.Store setup-pending marker so /api/session stops telling the
|
||||||
|
// frontend to show it. Everything the wizard actually configures (identity,
|
||||||
|
// .env, the operator account) is already saved as the operator moves
|
||||||
|
// through it via the same actions Server Settings/Operators use outside the
|
||||||
|
// wizard -- this action only marks that the walkthrough happened, so it
|
||||||
|
// never fails partway through something worth retrying.
|
||||||
|
func (s *server) handleCompleteSetupAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body completeSetupAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "complete-setup")
|
||||||
|
if meta.DryRun {
|
||||||
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", nil, "setup completion validated", nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.identity.MarkSetupComplete()
|
||||||
|
writeJSON(w, http.StatusOK, serverCommandResult(meta, "server.complete_setup", err, "setup marked complete", nil))
|
||||||
|
}
|
||||||
|
|
||||||
// --- .env editing --------------------------------------------------------
|
// --- .env editing --------------------------------------------------------
|
||||||
|
|
||||||
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleServerEnvAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
9
cmd/telesrv-admin/web/dist/assets/index-B8l4nVOK.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-B8l4nVOK.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-hP2uBwK-.js"></script>
|
<script type="module" crossorigin src="/assets/index-B8l4nVOK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BQqynmV9.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DW3ERnl8.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { BootScreen, Shell } from "./components/Layout";
|
import { BootScreen, Shell } from "./components/Layout";
|
||||||
|
import { SetupWizard } from "./components/SetupWizard";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { PermissionsProvider } from "./permissions";
|
import { permissionAll, permissionServerManage, PermissionsProvider } from "./permissions";
|
||||||
import { Routes } from "./pages/Routes";
|
import { Routes } from "./pages/Routes";
|
||||||
import { currentRoute, type RouteState } from "./routing";
|
import { currentRoute, type RouteState } from "./routing";
|
||||||
import type { AdminSession } from "./types";
|
import type { AdminSession } from "./types";
|
||||||
|
|
@ -55,6 +56,18 @@ export function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The wizard only ever shows to whoever can actually act on it -- a
|
||||||
|
// limited operator signing in before setup is finished just sees the
|
||||||
|
// normal (mostly empty) shell instead of a wizard whose every step would
|
||||||
|
// 403. setup_completed undefined (an admin binary old enough to predate
|
||||||
|
// the field) reads as "done", same convention as the type's doc comment.
|
||||||
|
const canRunSetupWizard = (session.permissions ?? []).some(
|
||||||
|
(permission) => permission === permissionAll || permission === permissionServerManage
|
||||||
|
);
|
||||||
|
if (session.setup_completed === false && canRunSetupWizard) {
|
||||||
|
return <SetupWizard />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
|
<PermissionsProvider permissions={session.permissions ?? []} hideThirdPartyVerification={session.hide_third_party_verification ?? true}>
|
||||||
<Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
<Shell actor={session.actor} apiLayers={session.api_layers} build={session.build} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||||
|
|
|
||||||
396
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal file
396
cmd/telesrv-admin/web/src/components/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
import { ArrowRight, Check, ImagePlus, Loader2, Rocket, UserPlus } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, errorMessage } from "../api";
|
||||||
|
import { RestartOverlay, ServerIconModal, useAdminRestartWatcher } from "../pages/ServerSettingsPage";
|
||||||
|
import { ThemeSwitch } from "../theme";
|
||||||
|
import { AppBackground } from "./AppBackground";
|
||||||
|
import { Alert } from "./ui";
|
||||||
|
|
||||||
|
// The first-run wizard: shown instead of the normal shell exactly once, when
|
||||||
|
// GET /api/session answers setup_completed=false (see
|
||||||
|
// identity.Store.SetupPending and cmd/telesrv-admin/server.go's
|
||||||
|
// handleSession). It covers the same ground
|
||||||
|
// tui-panel/server-panel.py's SetupWizardScreen used to ask for in the
|
||||||
|
// terminal before the server could even start -- server identity, the public
|
||||||
|
// network fields a real deployment needs, and a named operator account to
|
||||||
|
// replace the generated break-glass password -- except every field here
|
||||||
|
// already has a working default (see quickstart's bootstrap_env), so nothing
|
||||||
|
// blocks Start anymore. This just walks through customizing it.
|
||||||
|
//
|
||||||
|
// Every step's "Continue" calls the same /api/actions/* route the equivalent
|
||||||
|
// Server Settings / Operators screen uses, with a fixed reason instead of an
|
||||||
|
// operator-typed one and confirm:true immediately (no dry-run screen). The
|
||||||
|
// rest of the panel asks an operator to justify a change to a live,
|
||||||
|
// populated deployment; here the operator IS the only account that has ever
|
||||||
|
// existed, configuring a server nobody else is using yet -- asking "why are
|
||||||
|
// you naming your own server" is friction with no audit value.
|
||||||
|
const WIZARD_REASON = "Set from the first-run setup wizard";
|
||||||
|
|
||||||
|
type StepId = "welcome" | "identity" | "network" | "account" | "done";
|
||||||
|
|
||||||
|
const STEP_ORDER: StepId[] = ["welcome", "identity", "network", "account", "done"];
|
||||||
|
const STEP_LABEL: Record<StepId, string> = {
|
||||||
|
welcome: "Welcome",
|
||||||
|
identity: "Identity",
|
||||||
|
network: "Network",
|
||||||
|
account: "Account",
|
||||||
|
done: "Done"
|
||||||
|
};
|
||||||
|
|
||||||
|
// No prop for "leave the wizard early": this only ever shows on a genuinely
|
||||||
|
// first-ever start, before there is a real deployment for a Skip to defer
|
||||||
|
// anything about -- see the Welcome step. The only way out is Done's
|
||||||
|
// "Finish setup & restart", which reloads the page once the new process
|
||||||
|
// answers; that reload is what dismisses this component (a fresh
|
||||||
|
// /api/session comes back with setup_completed=true).
|
||||||
|
export function SetupWizard() {
|
||||||
|
const [step, setStep] = useState<StepId>("welcome");
|
||||||
|
|
||||||
|
function goTo(next: StepId) {
|
||||||
|
setStep(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page setup-wizard-page">
|
||||||
|
<AppBackground />
|
||||||
|
<section className="login-panel setup-wizard-panel">
|
||||||
|
<div className="login-head">
|
||||||
|
<div className="brand brand-elevated">
|
||||||
|
<span>
|
||||||
|
<strong>{"Let's set up your server"}</strong>
|
||||||
|
<small>{"First-run setup"}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="login-head-actions">
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wizard-steps">
|
||||||
|
{STEP_ORDER.map((id, index) => {
|
||||||
|
const currentIndex = STEP_ORDER.indexOf(step);
|
||||||
|
const state = index === currentIndex ? "active" : index < currentIndex ? "done" : "";
|
||||||
|
return (
|
||||||
|
<div key={id} className={`command-step ${state}`}>
|
||||||
|
<span>{index < currentIndex ? <Check size={12} /> : index + 1}</span>
|
||||||
|
<strong>{STEP_LABEL[id]}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "welcome" && <WelcomeStep onNext={() => goTo("identity")} />}
|
||||||
|
{step === "identity" && <IdentityStep onNext={() => goTo("network")} />}
|
||||||
|
{step === "network" && <NetworkStep onNext={() => goTo("account")} />}
|
||||||
|
{step === "account" && <AccountStep onNext={() => goTo("done")} />}
|
||||||
|
{step === "done" && <DoneStep />}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardActions({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="wizard-actions">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WelcomeStep({ onNext }: { onNext: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="wizard-step-body">
|
||||||
|
<p className="wizard-welcome-greeting">{"Hi!"}</p>
|
||||||
|
<p>
|
||||||
|
{"Let's get your server set up -- a name, an address for clients, and an account of "}
|
||||||
|
{"your own. Takes about a minute, and everything here stays editable later."}
|
||||||
|
</p>
|
||||||
|
<WizardActions>
|
||||||
|
<button className="btn primary icon-text" type="button" onClick={onNext}>
|
||||||
|
{"Get started"} <ArrowRight size={15} />
|
||||||
|
</button>
|
||||||
|
</WizardActions>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IdentityStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [iconExt, setIconExt] = useState<string | undefined>(undefined);
|
||||||
|
const [iconModalOpen, setIconModalOpen] = useState(false);
|
||||||
|
const [iconBust, setIconBust] = useState(0);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.serverIdentity()
|
||||||
|
.then((info) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setName(info.name);
|
||||||
|
setDescription(info.description);
|
||||||
|
setIconExt(info.icon_ext);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.action("/api/actions/set-server-identity", {
|
||||||
|
command_id: "", reason: WIZARD_REASON, confirm: true, name, description
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard-step-body">
|
||||||
|
<p className="wizard-step-hint">{"Shown to clients when they add this server, and in the sidebar here."}</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="wizard-identity-row">
|
||||||
|
<div className="avatar-edit-slot">
|
||||||
|
{iconExt ? (
|
||||||
|
<img className="avatar-photo-img" src={api.serverIconURL() + `&b=${iconBust}`} alt="" style={{ width: 72, height: 72 }} />
|
||||||
|
) : (
|
||||||
|
<div className="avatar-fallback server-icon-fallback" style={{ width: 72, height: 72 }}>
|
||||||
|
<ImagePlus size={22} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="icon-btn avatar-edit-btn" type="button" aria-label={"Add server icon"} onClick={() => setIconModalOpen(true)}>
|
||||||
|
<ImagePlus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="server-identity-fields">
|
||||||
|
<label className="form-field"><span>{"Name"}</span><input value={name} maxLength={128} placeholder={"OwpenGram"} disabled={!loaded} onChange={(event) => setName(event.target.value)} /></label>
|
||||||
|
<label className="form-field"><span>{"Description"}</span><textarea rows={2} value={description} maxLength={512} disabled={!loaded} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WizardActions>
|
||||||
|
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
|
||||||
|
{"Continue"}
|
||||||
|
</button>
|
||||||
|
</WizardActions>
|
||||||
|
{iconModalOpen && (
|
||||||
|
<ServerIconModal
|
||||||
|
hasIcon={!!iconExt}
|
||||||
|
autoReason={WIZARD_REASON}
|
||||||
|
onClose={() => setIconModalOpen(false)}
|
||||||
|
onDone={() => { setIconBust((n) => n + 1); void api.serverIdentity().then((info) => setIconExt(info.icon_ext)); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NETWORK_FIELDS: { key: string; label: string; hint: string; placeholder: string }[] = [
|
||||||
|
{
|
||||||
|
key: "TELESRV_ADVERTISE_IP",
|
||||||
|
label: "Server public IP or hostname",
|
||||||
|
hint: "What clients connect to. Fine to leave as 127.0.0.1 for local testing.",
|
||||||
|
placeholder: "127.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "TELESRV_PUBLIC_BASE_URL",
|
||||||
|
label: "Public base URL",
|
||||||
|
hint: "Used for links this server generates -- invites, sticker packs. e.g. https://example.com",
|
||||||
|
placeholder: "http://127.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "TELESRV_PUBLIC_APP_SCHEME",
|
||||||
|
label: "Custom app link scheme",
|
||||||
|
hint: "Must match what your client builds were compiled with.",
|
||||||
|
placeholder: "owpg"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function NetworkStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.serverEnv()
|
||||||
|
.then((groups) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const field of group.fields) {
|
||||||
|
if (NETWORK_FIELDS.some((f) => f.key === field.key)) next[field.key] = field.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValues(next);
|
||||||
|
setLoaded(true);
|
||||||
|
})
|
||||||
|
.catch((err) => { if (!cancelled) { setError(errorMessage(err)); setLoaded(true); } });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.action("/api/actions/update-server-env", {
|
||||||
|
command_id: "", reason: WIZARD_REASON, confirm: true, values
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard-step-body">
|
||||||
|
<p className="wizard-step-hint">{"Takes effect once setup finishes below -- that last step restarts the server."}</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
{NETWORK_FIELDS.map((field) => (
|
||||||
|
<label key={field.key} className="form-field env-field">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<span className="env-field-desc">{field.hint}</span>
|
||||||
|
<input
|
||||||
|
value={values[field.key] ?? ""}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={!loaded}
|
||||||
|
onChange={(event) => setValues((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<WizardActions>
|
||||||
|
<button className="btn primary icon-text" type="button" disabled={busy || !loaded} onClick={() => void submit()}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <ArrowRight size={15} />}
|
||||||
|
{"Continue"}
|
||||||
|
</button>
|
||||||
|
</WizardActions>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountStep({ onNext }: { onNext: () => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const incomplete = username.trim().length < 3 || password.trim() === "";
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (incomplete) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await api.action("/api/actions/create-admin-operator", {
|
||||||
|
command_id: "", reason: WIZARD_REASON, confirm: true,
|
||||||
|
username: username.trim(), password, permissions: ["*"], enabled: true
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard-step-body">
|
||||||
|
<p className="wizard-step-hint">{"Replace the generated password with a login of your own."}</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<label className="form-field"><span>{"Username"}</span><input autoFocus value={username} spellCheck={false} autoCapitalize="none" placeholder={"letters, digits, dot, dash or underscore"} onChange={(event) => setUsername(event.target.value)} /></label>
|
||||||
|
<label className="form-field"><span>{"Password"}</span><input type="password" value={password} autoComplete="new-password" onChange={(event) => setPassword(event.target.value)} /></label>
|
||||||
|
<WizardActions>
|
||||||
|
<button className="btn" type="button" onClick={onNext}>{"Skip for now"}</button>
|
||||||
|
<button className="btn primary icon-text" type="button" disabled={busy || incomplete} onClick={() => void submit()}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <UserPlus size={15} />}
|
||||||
|
{"Create account & continue"}
|
||||||
|
</button>
|
||||||
|
</WizardActions>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoneStep marks setup complete and restarts, rather than leaving that for
|
||||||
|
// later -- the network fields two steps back only take effect after a
|
||||||
|
// restart, and asking the operator to remember to go find Restart in
|
||||||
|
// Services afterward is exactly the kind of loose end this wizard exists to
|
||||||
|
// close. useAdminRestartWatcher (shared with Services' own Restart button)
|
||||||
|
// reloads the page once a genuinely new process answers; passed a
|
||||||
|
// beforeReload that logs out first here specifically (Services' own Restart
|
||||||
|
// button doesn't), so the reload lands back on the login form instead of
|
||||||
|
// straight into the shell still signed in as "owpengram" -- the whole point
|
||||||
|
// of the Account step just before this one was to have a real login to
|
||||||
|
// switch to instead. The generated password stops working server-side the
|
||||||
|
// moment complete-setup runs (see identity.Store.TemporaryPasswordMatches),
|
||||||
|
// independent of this logout; this just makes sure the browser doesn't
|
||||||
|
// carry the old session forward and paper over that.
|
||||||
|
function DoneStep() {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const restartWatcher = useAdminRestartWatcher();
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const completeResult = await api.action("/api/actions/complete-setup", { command_id: "", reason: WIZARD_REASON, confirm: true });
|
||||||
|
if (completeResult.error) {
|
||||||
|
setError(completeResult.error);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restartResult = await api.action("/api/actions/restart-server", { command_id: "", reason: WIZARD_REASON, confirm: true });
|
||||||
|
if (restartResult.error) {
|
||||||
|
setError(restartResult.error);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void restartWatcher.watch(150000, { beforeReload: async () => { await api.logout(); } });
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard-step-body">
|
||||||
|
<p>
|
||||||
|
{"That's the essentials. Finishing restarts the server so the network settings from the previous "}
|
||||||
|
{"step take effect. Everything here stays editable from Server Settings and Operators any time."}
|
||||||
|
</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<WizardActions>
|
||||||
|
<button className="btn primary icon-text" type="button" disabled={busy} onClick={() => void finish()}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : <Rocket size={15} />}
|
||||||
|
{"Finish setup & restart"}
|
||||||
|
</button>
|
||||||
|
</WizardActions>
|
||||||
|
{restartWatcher.waiting && (
|
||||||
|
<RestartOverlay label={"Restarting owpengram-server and the admin panel..."} timedOut={false} onDismiss={restartWatcher.dismiss} />
|
||||||
|
)}
|
||||||
|
{restartWatcher.timedOut && (
|
||||||
|
<RestartOverlay label={"Restarting owpengram-server and the admin panel..."} timedOut={true} onDismiss={restartWatcher.dismiss} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -290,10 +290,14 @@ function LoginNotificationsSection() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClose: () => void; onDone: () => void }) {
|
// autoReason skips the "why is this changing" prompt in favor of a fixed
|
||||||
|
// reason -- for the first-run wizard, where there is no prior state to
|
||||||
|
// justify changing away from and no one else's icon to be overwriting.
|
||||||
|
export function ServerIconModal({ hasIcon, onClose, onDone, autoReason }: { hasIcon: boolean; onClose: () => void; onDone: () => void; autoReason?: string }) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [previewURL, setPreviewURL] = useState("");
|
const [previewURL, setPreviewURL] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [typedReason, setTypedReason] = useState("");
|
||||||
|
const reason = autoReason ?? typedReason;
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
|
@ -375,7 +379,9 @@ function ServerIconModal({ hasIcon, onClose, onDone }: { hasIcon: boolean; onClo
|
||||||
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
|
<span className="gift-file-copy"><span className="gift-field-label">{"New icon"}</span><strong>{file ? file.name : "Choose a PNG, JPEG, WebP, or GIF image"}</strong></span>
|
||||||
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setReason(event.target.value)} /></label>
|
{autoReason === undefined && (
|
||||||
|
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={typedReason} placeholder={"Briefly describe why the server icon is changing"} onChange={(event) => setTypedReason(event.target.value)} /></label>
|
||||||
|
)}
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|
@ -495,12 +501,12 @@ function sleep(ms: number): Promise<void> {
|
||||||
// process is up, not just that the old one is still slow -- then reloading
|
// process is up, not just that the old one is still slow -- then reloading
|
||||||
// the page. A timeout surfaces as a message with a manual reload button
|
// the page. A timeout surfaces as a message with a manual reload button
|
||||||
// instead of spinning forever if something went wrong server-side.
|
// instead of spinning forever if something went wrong server-side.
|
||||||
function useAdminRestartWatcher() {
|
export function useAdminRestartWatcher() {
|
||||||
const [waiting, setWaiting] = useState(false);
|
const [waiting, setWaiting] = useState(false);
|
||||||
const [timedOut, setTimedOut] = useState(false);
|
const [timedOut, setTimedOut] = useState(false);
|
||||||
const cancelled = useRef(false);
|
const cancelled = useRef(false);
|
||||||
|
|
||||||
const watch = useCallback(async (timeoutMs = 150000) => {
|
const watch = useCallback(async (timeoutMs = 150000, options?: { beforeReload?: () => Promise<void> | void }) => {
|
||||||
cancelled.current = false;
|
cancelled.current = false;
|
||||||
setTimedOut(false);
|
setTimedOut(false);
|
||||||
setWaiting(true);
|
setWaiting(true);
|
||||||
|
|
@ -518,6 +524,12 @@ function useAdminRestartWatcher() {
|
||||||
try {
|
try {
|
||||||
const session = await api.session();
|
const session = await api.session();
|
||||||
if (session.boot_id && session.boot_id !== baseline) {
|
if (session.boot_id && session.boot_id !== baseline) {
|
||||||
|
// beforeReload runs against the new process (this session read
|
||||||
|
// already proved it's up) and can't fail the reload -- a reload
|
||||||
|
// an operator is staring at a spinner for shouldn't hang on it.
|
||||||
|
if (options?.beforeReload) {
|
||||||
|
await Promise.resolve(options.beforeReload()).catch(() => undefined);
|
||||||
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -539,7 +551,7 @@ function useAdminRestartWatcher() {
|
||||||
return { waiting, timedOut, watch, dismiss };
|
return { waiting, timedOut, watch, dismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
|
export function RestartOverlay({ label, timedOut, onDismiss }: { label: string; timedOut: boolean; onDismiss: () => void }) {
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation">
|
||||||
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
|
<section className="modal command-modal restart-overlay" role="dialog" aria-modal="true" aria-label={label}>
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,83 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* First-run setup wizard (components/SetupWizard.tsx) -- built on the same
|
||||||
|
.login-page/.login-panel shell as the sign-in screen, just wider: this one
|
||||||
|
holds a name+description+icon row and env fields, not two single inputs. */
|
||||||
|
.setup-wizard-panel {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: min(760px, calc(100vh - 48px));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-steps .command-step {
|
||||||
|
flex: 1 1 0;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-steps .command-step strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-body > p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-body > p.wizard-step-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-step-body > p.wizard-welcome-greeting {
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-identity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-identity-row .server-identity-fields {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.wizard-steps .command-step strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wizard-identity-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.boot-screen {
|
.boot-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,11 @@ export type AdminSession = {
|
||||||
// 404, so this is a UI convenience on top of a real enforcement, not the
|
// 404, so this is a UI convenience on top of a real enforcement, not the
|
||||||
// enforcement itself.
|
// enforcement itself.
|
||||||
hide_third_party_verification?: boolean;
|
hide_third_party_verification?: boolean;
|
||||||
|
// False until the first-run setup wizard has been finished -- see
|
||||||
|
// identity.Store.SetupPending. Missing/undefined is treated as true (an
|
||||||
|
// admin binary older than this field never gates on it), so only an
|
||||||
|
// explicit false shows the wizard.
|
||||||
|
setup_completed?: boolean;
|
||||||
// Random per admin-process-start value -- see the Go handler's doc
|
// Random per admin-process-start value -- see the Go handler's doc
|
||||||
// comment. Used by Server Settings' Restart/Update flow to detect a
|
// comment. Used by Server Settings' Restart/Update flow to detect a
|
||||||
// genuinely new admin process after asking it to bounce.
|
// genuinely new admin process after asking it to bounce.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
package identity
|
package identity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -19,6 +20,29 @@ import (
|
||||||
const (
|
const (
|
||||||
metaFileName = "identity.json"
|
metaFileName = "identity.json"
|
||||||
iconBaseName = "icon"
|
iconBaseName = "icon"
|
||||||
|
// setupPendingFileName marks an install as not yet through the
|
||||||
|
// first-run wizard. Deliberately a sentinel file next to identity.json,
|
||||||
|
// not a field inside it: identity.json's own content changes *during*
|
||||||
|
// the wizard -- the Identity step saves a name well before Done is ever
|
||||||
|
// reached -- so a signal derived from that content (e.g. "name is set")
|
||||||
|
// flips to "done" the moment that one step is saved, not when the
|
||||||
|
// wizard actually finishes. This file is created once, by quickstart's
|
||||||
|
// bootstrap_env() the moment it creates a fresh .env (see
|
||||||
|
// tui-panel/server-panel.py), and removed once, by MarkSetupComplete --
|
||||||
|
// nothing in between (including a server restart mid-wizard) touches
|
||||||
|
// it, so "still pending" survives every step until Done really is
|
||||||
|
// reached.
|
||||||
|
setupPendingFileName = ".setup_pending"
|
||||||
|
// passwordTemporaryFileName holds the exact value of the password
|
||||||
|
// quickstart's bootstrap_env() generated for the very first login on a
|
||||||
|
// fresh install (see tui-panel/server-panel.py) -- created alongside
|
||||||
|
// setupPendingFileName, never on its own. Storing the value itself
|
||||||
|
// (rather than just the file's existence) is what lets
|
||||||
|
// TemporaryPasswordMatches tell "still the generated one" apart from
|
||||||
|
// "an operator has since set their own", however that happened -- a
|
||||||
|
// manually typed .env edit included, since that never goes through this
|
||||||
|
// package at all.
|
||||||
|
passwordTemporaryFileName = ".admin_password_temporary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Info is the editable identity shown to clients.
|
// Info is the editable identity shown to clients.
|
||||||
|
|
@ -71,6 +95,49 @@ func (s *Store) iconPath(ext string) string {
|
||||||
return filepath.Join(s.dir, iconBaseName+ext)
|
return filepath.Join(s.dir, iconBaseName+ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) setupPendingPath() string {
|
||||||
|
return filepath.Join(s.dir, setupPendingFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) passwordTemporaryPath() string {
|
||||||
|
return filepath.Join(s.dir, passwordTemporaryFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupPending reports whether the first-run wizard still has work to do.
|
||||||
|
// A deployment that predates this feature (upgraded from an older admin
|
||||||
|
// binary, or one that was never bootstrapped through quickstart at all)
|
||||||
|
// never had this file created for it, so it reads as "not pending" --
|
||||||
|
// already done, no wizard -- regardless of what its identity.json happens
|
||||||
|
// to contain. A nil Store (a minimal test fixture, say) reads the same way
|
||||||
|
// -- "not pending" is the answer that costs nothing if it's wrong.
|
||||||
|
func (s *Store) SetupPending() bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(s.setupPendingPath())
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporaryPasswordMatches reports whether password is exactly the value
|
||||||
|
// quickstart auto-generated for the very first login. cmd/telesrv-admin's
|
||||||
|
// validSecret pairs this with SetupPending: the generated password
|
||||||
|
// authenticates only until the wizard finishes, so a string that was
|
||||||
|
// printed once to a terminal and never chosen by anyone doesn't go on
|
||||||
|
// being a standing credential forever. It never matches a password an
|
||||||
|
// operator set themselves, at any point -- there's no file to fool it
|
||||||
|
// with, only an exact value comparison. A nil Store never matches, same
|
||||||
|
// reasoning as SetupPending.
|
||||||
|
func (s *Store) TemporaryPasswordMatches(password string) bool {
|
||||||
|
if s == nil || password == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stored, err := os.ReadFile(s.passwordTemporaryPath())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(stored, []byte(password)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
// Get reads the current identity. A missing file is not an error -- it just
|
// Get reads the current identity. A missing file is not an error -- it just
|
||||||
// means nothing has been configured yet, so Info{} (all empty) is returned.
|
// means nothing has been configured yet, so Info{} (all empty) is returned.
|
||||||
func (s *Store) Get() (Info, error) {
|
func (s *Store) Get() (Info, error) {
|
||||||
|
|
@ -136,6 +203,25 @@ func (s *Store) SetLoginCodeMessageTemplate(template string) error {
|
||||||
return s.save(info)
|
return s.save(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkSetupComplete removes the pending marker so SetupPending reads false
|
||||||
|
// from here on. Idempotent -- calling it again once the marker is already
|
||||||
|
// gone is a no-op, not an error.
|
||||||
|
//
|
||||||
|
// Deliberately leaves the temporary-password marker in place: validSecret
|
||||||
|
// needs TemporaryPasswordMatches to keep recognizing that exact value
|
||||||
|
// *after* setup completes, which is the whole mechanism that retires it --
|
||||||
|
// deleting the marker here would make that check quietly stop matching and
|
||||||
|
// the password would keep working forever, the opposite of the point.
|
||||||
|
// Nothing about leaving it costs anything: it never matches a different
|
||||||
|
// password (an operator's real one, whenever they set it), and this
|
||||||
|
// package's only reader of it is that one comparison.
|
||||||
|
func (s *Store) MarkSetupComplete() error {
|
||||||
|
if err := os.Remove(s.setupPendingPath()); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("identity: remove setup-pending marker: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetIcon replaces the icon file (removing any previous one under a
|
// SetIcon replaces the icon file (removing any previous one under a
|
||||||
// different extension) and records its extension in identity.json.
|
// different extension) and records its extension in identity.json.
|
||||||
// ext must include the leading dot (e.g. ".png").
|
// ext must include the leading dot (e.g. ".png").
|
||||||
|
|
|
||||||
|
|
@ -197,3 +197,98 @@ func TestStoreWelcomeMessageTemplatesPreservedAcrossTextEdits(t *testing.T) {
|
||||||
t.Fatalf("welcome message templates lost after unrelated SetText: %+v", info)
|
t.Fatalf("welcome message templates lost after unrelated SetText: %+v", info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStoreSetupPendingSurvivesIdentityWrites is the regression case for the
|
||||||
|
// bug where "setup complete" was inferred from identity.json's own content
|
||||||
|
// (a non-empty name): the wizard's own Identity step calls SetText well
|
||||||
|
// before Done is ever reached, which made that content-based check flip to
|
||||||
|
// "done" mid-wizard -- a restart-and-reload partway through skipped
|
||||||
|
// straight to the normal shell. SetupPending must stay true across any
|
||||||
|
// number of unrelated identity writes and clear only via MarkSetupComplete.
|
||||||
|
func TestStoreSetupPendingSurvivesIdentityWrites(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewStore(dir)
|
||||||
|
|
||||||
|
// Not pending at all until something (quickstart's bootstrap_env, in
|
||||||
|
// production) creates the marker -- an install this store never saw
|
||||||
|
// bootstrapped is treated as predating the wizard, not as mid-wizard.
|
||||||
|
if s.SetupPending() {
|
||||||
|
t.Fatal("expected SetupPending() == false before the marker file exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, setupPendingFileName), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.SetupPending() {
|
||||||
|
t.Fatal("expected SetupPending() == true once the marker file exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Identity step's save, and everything else short of Done.
|
||||||
|
if err := s.SetText("Demo Server", "A test server"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.SetIcon([]byte{1, 2, 3}, ".png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.SetupPending() {
|
||||||
|
t.Fatal("expected SetupPending() to stay true after unrelated identity writes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.MarkSetupComplete(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.SetupPending() {
|
||||||
|
t.Fatal("expected SetupPending() == false after MarkSetupComplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: calling it again once already gone is not an error.
|
||||||
|
if err := s.MarkSetupComplete(); err != nil {
|
||||||
|
t.Fatalf("MarkSetupComplete should be idempotent, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoreTemporaryPasswordMatches covers the one-time-login password
|
||||||
|
// quickstart generates: it must match only its own exact value, never an
|
||||||
|
// operator-chosen one. MarkSetupComplete deliberately does NOT erase this
|
||||||
|
// marker (see that method's doc comment) -- retiring the password is
|
||||||
|
// validSecret's job, combining this with SetupPending; on its own,
|
||||||
|
// TemporaryPasswordMatches keeps recognizing the same stored value even
|
||||||
|
// after the wizard finishes, which is exactly what lets that combination
|
||||||
|
// work at every login from then on, not just the first one after Done.
|
||||||
|
func TestStoreTemporaryPasswordMatches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewStore(dir)
|
||||||
|
|
||||||
|
if s.TemporaryPasswordMatches("anything") {
|
||||||
|
t.Fatal("expected no match before the marker file exists")
|
||||||
|
}
|
||||||
|
if s.TemporaryPasswordMatches("") {
|
||||||
|
t.Fatal("an empty password must never match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, passwordTemporaryFileName), []byte("generated-pw-123"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.TemporaryPasswordMatches("generated-pw-123") {
|
||||||
|
t.Fatal("expected a match against the exact stored value")
|
||||||
|
}
|
||||||
|
if s.TemporaryPasswordMatches("something-an-operator-typed") {
|
||||||
|
t.Fatal("a different password must never match the marker")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.MarkSetupComplete(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !s.TemporaryPasswordMatches("generated-pw-123") {
|
||||||
|
t.Fatal("expected the match to survive MarkSetupComplete -- see its doc comment for why")
|
||||||
|
}
|
||||||
|
if s.SetupPending() {
|
||||||
|
t.Fatal("expected SetupPending() == false after MarkSetupComplete regardless")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ from textual.widgets.option_list import Option
|
||||||
|
|
||||||
IS_WINDOWS = platform.system() == "Windows"
|
IS_WINDOWS = platform.system() == "Windows"
|
||||||
|
|
||||||
|
# Mirrors breakGlassUsername in cmd/telesrv-admin/adminauth.go -- the fixed
|
||||||
|
# name authenticateLogin resolves against TELESRV_ADMIN_UI_PASSWORD/_TOKEN
|
||||||
|
# rather than a named operator row, i.e. whatever quickstart just generated
|
||||||
|
# a password for. Printed alongside that password so "log in with what" has
|
||||||
|
# an answer -- the login form itself has no default filled in.
|
||||||
|
ADMIN_BREAK_GLASS_USERNAME = "owpengram"
|
||||||
|
|
||||||
# psutil.cpu_percent()'s first call always returns a meaningless 0.0 baseline
|
# psutil.cpu_percent()'s first call always returns a meaningless 0.0 baseline
|
||||||
# (it measures against process start); priming it once here means the first
|
# (it measures against process start); priming it once here means the first
|
||||||
# real reading in the stats timer is already a proper since-last-call delta.
|
# real reading in the stats timer is already a proper since-last-call delta.
|
||||||
|
|
@ -64,6 +71,21 @@ ENV_FILE = ROOT / ".env"
|
||||||
ENV_EXAMPLE_FILE = ROOT / ".env.example"
|
ENV_EXAMPLE_FILE = ROOT / ".env.example"
|
||||||
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
|
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
|
||||||
STATE_FILE = ROOT / ".server_panel.json"
|
STATE_FILE = ROOT / ".server_panel.json"
|
||||||
|
# Matches .env.example's TELESRV_IDENTITY_DIR default -- reliable at
|
||||||
|
# bootstrap time specifically because .env doesn't exist yet, so nothing
|
||||||
|
# could have overridden it. See SETUP_PENDING_FILE below.
|
||||||
|
IDENTITY_DIR = ROOT / "data" / "identity"
|
||||||
|
# Marks an install as not yet through the first-run wizard -- see
|
||||||
|
# internal/identity.Store.SetupPending's doc comment for the full reasoning
|
||||||
|
# (identity.json's own content changes mid-wizard, well before Done, so it
|
||||||
|
# can't be the signal). bootstrap_env() creates this file; the wizard's own
|
||||||
|
# complete-setup action (identity.Store.MarkSetupComplete) removes it.
|
||||||
|
SETUP_PENDING_FILE = IDENTITY_DIR / ".setup_pending"
|
||||||
|
# Holds the exact value of the password bootstrap_env() generates below, so
|
||||||
|
# identity.Store.TemporaryPasswordMatches can tell it apart from a password
|
||||||
|
# an operator actually chose -- see that method's doc comment. Written with
|
||||||
|
# no trailing newline; the Go side compares raw bytes.
|
||||||
|
PASSWORD_TEMPORARY_FILE = IDENTITY_DIR / ".admin_password_temporary"
|
||||||
|
|
||||||
SERVER_EXE = BIN_DIR / ("owpengram-server.exe" if IS_WINDOWS else "owpengram-server")
|
SERVER_EXE = BIN_DIR / ("owpengram-server.exe" if IS_WINDOWS else "owpengram-server")
|
||||||
ADMIN_EXE = BIN_DIR / ("owpengram-admin-panel.exe" if IS_WINDOWS else "owpengram-admin-panel")
|
ADMIN_EXE = BIN_DIR / ("owpengram-admin-panel.exe" if IS_WINDOWS else "owpengram-admin-panel")
|
||||||
|
|
@ -631,19 +653,77 @@ def server_public_key_pem() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_env() -> str | None:
|
def bootstrap_env() -> str | None:
|
||||||
"""Creates .env from .env.example if this is a fresh install. Returns
|
"""Creates .env from .env.example on a fresh install, or -- just as
|
||||||
the freshly generated admin password, or None if .env already existed
|
important -- patches in whichever of the three secrets telesrv-admin
|
||||||
(nothing was generated or touched -- an existing password is never read
|
refuses to boot without (TELESRV_ADMIN_API_TOKEN,
|
||||||
back for display, generated or not)."""
|
TELESRV_ADMIN_SESSION_KEY, and a password/token pair) are missing from
|
||||||
if is_initialized():
|
an .env that already exists. That second case is not hypothetical: an
|
||||||
return None
|
.env can predate these fields entirely (hand-written before this admin
|
||||||
|
binary existed, or missing them for any other reason), and until this
|
||||||
|
ran, quickstart's own "already exists, nothing to do" check skipped
|
||||||
|
them forever -- the admin panel would launch and immediately exit on
|
||||||
|
main.go's "TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is
|
||||||
|
required", with quickstart none the wiser (launch() doesn't check the
|
||||||
|
child's exit code) and "Launched." printed anyway.
|
||||||
|
|
||||||
|
Also fills in TELESRV_ADMIN_API_ADDR when it's blank, matching
|
||||||
|
cmd/telesrv-admin/main.go's own defaultAdminAPIAddr (127.0.0.1:2599):
|
||||||
|
that's owpengram-server's *internal* admin API -- the one the admin
|
||||||
|
panel calls into for anything the read-only Postgres connection can't
|
||||||
|
serve on its own, e.g. proxying an account's live avatar bytes, or any
|
||||||
|
of the domain mutations (freeze, grant premium, ...). .env.example
|
||||||
|
ships it blank on purpose, so admin-panel functionality that depends on
|
||||||
|
it silently no-ops (an <img> just falls back to initials; a mutation
|
||||||
|
action surfaces a "connection refused") until someone notices and sets
|
||||||
|
it -- there's no reason to make a self-hoster running both binaries
|
||||||
|
together, which is exactly what quickstart does, discover and fix that
|
||||||
|
by hand.
|
||||||
|
|
||||||
|
Returns the freshly generated admin password if one was generated
|
||||||
|
(fresh install, or an existing .env that had neither a password nor a
|
||||||
|
token), or None if nothing needed generating -- an existing password is
|
||||||
|
never read back for display either way.
|
||||||
|
|
||||||
|
SETUP_PENDING_FILE (which gates the first-run wizard) and
|
||||||
|
PASSWORD_TEMPORARY_FILE (which makes that generated password stop
|
||||||
|
working once the wizard finishes -- see
|
||||||
|
identity.Store.TemporaryPasswordMatches) are only ever written on the
|
||||||
|
fresh-install branch. Patching secrets into an .env that was already
|
||||||
|
there isn't a first run -- there is likely already a real identity,
|
||||||
|
real data, real users behind it -- so it must never force that install
|
||||||
|
through the wizard, and a password generated to plug that gap has to
|
||||||
|
go on working indefinitely (nothing will ever run MarkSetupComplete to
|
||||||
|
retire it, since SetupPending was never true for it in the first
|
||||||
|
place)."""
|
||||||
|
fresh_install = not is_initialized()
|
||||||
values = current_env_values(parse_env_template())
|
values = current_env_values(parse_env_template())
|
||||||
admin_password = secrets.token_urlsafe(12)
|
|
||||||
|
generated_password = None
|
||||||
|
changed = False
|
||||||
|
if not values.get("TELESRV_ADMIN_UI_PASSWORD") and not values.get("TELESRV_ADMIN_UI_TOKEN"):
|
||||||
|
generated_password = secrets.token_urlsafe(12)
|
||||||
|
values["TELESRV_ADMIN_UI_PASSWORD"] = generated_password
|
||||||
|
changed = True
|
||||||
|
if not values.get("TELESRV_ADMIN_API_TOKEN"):
|
||||||
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
|
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
|
||||||
|
changed = True
|
||||||
|
if not values.get("TELESRV_ADMIN_SESSION_KEY"):
|
||||||
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
|
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
|
||||||
values["TELESRV_ADMIN_UI_PASSWORD"] = admin_password
|
changed = True
|
||||||
|
if not values.get("TELESRV_ADMIN_API_ADDR"):
|
||||||
|
values["TELESRV_ADMIN_API_ADDR"] = "127.0.0.1:2599"
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not fresh_install and not changed:
|
||||||
|
return None # existing .env, and every required secret was already set
|
||||||
|
|
||||||
save_env(values)
|
save_env(values)
|
||||||
return admin_password
|
if fresh_install:
|
||||||
|
IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
SETUP_PENDING_FILE.touch()
|
||||||
|
if generated_password:
|
||||||
|
PASSWORD_TEMPORARY_FILE.write_text(generated_password)
|
||||||
|
return generated_password
|
||||||
|
|
||||||
|
|
||||||
def _run_naming_helper(cmd: list[str]) -> str:
|
def _run_naming_helper(cmd: list[str]) -> str:
|
||||||
|
|
@ -730,8 +810,8 @@ def quickstart() -> int:
|
||||||
url, _ = info
|
url, _ = info
|
||||||
print(f"Open {url} to finish setting up your server.")
|
print(f"Open {url} to finish setting up your server.")
|
||||||
if generated_password:
|
if generated_password:
|
||||||
|
print(f"Login: {ADMIN_BREAK_GLASS_USERNAME}")
|
||||||
print(f"Initial admin password: {generated_password}")
|
print(f"Initial admin password: {generated_password}")
|
||||||
print("(create a named operator account, or change this one, from Operators in the admin panel)")
|
|
||||||
print()
|
print()
|
||||||
print("For stop/restart/logs/.env editing from the terminal instead: owpengram-server.bat panel")
|
print("For stop/restart/logs/.env editing from the terminal instead: owpengram-server.bat panel")
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue