v0.22.0: First-run setup wizard, local infra backup, hub verification
New controller features:
- Web-based setup wizard replaces docker-setup.sh interactive config
- Dual listener: :8080 (Traefik) + :8081 (direct HTTP for LAN)
- Drive scanner finds .felhom-infra-backup/ on all block devices
- Hub recovery pull (GET /api/v1/recovery/{id}) with retrieval password
- Fresh install: Hub config download or manual wizard
- CSRF protection, state persistence, Hungarian UI
- Local infra backup written to all connected drives after each backup cycle
- .felhom-infra-backup/backup.json + metadata.json with SHA256 checksum
- Hub verification: parse customer_blocked from report push response
- Limited mode after 7 days without verification
- Recovery info page on Settings + recovery-info.txt file generation
- Pending events queue: DR events sent to Hub on next report push
- docker-setup.sh v6.0.0: removed interactive wizard, minimal controller.yaml only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const csrfCookieName = "felhom_csrf"
|
||||
const csrfFormField = "_csrf"
|
||||
|
||||
// generateCSRFToken creates a random 32-byte hex token.
|
||||
func generateCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to time-based (extremely unlikely)
|
||||
return "fallback-csrf-token"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// setCSRFCookie sets the CSRF cookie on the response.
|
||||
func setCSRFCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
HttpOnly: false, // JavaScript needs to read it for AJAX if needed
|
||||
})
|
||||
}
|
||||
|
||||
// validateCSRF checks that the form field matches the cookie.
|
||||
func validateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
formToken := r.FormValue(csrfFormField)
|
||||
if formToken == "" {
|
||||
return false
|
||||
}
|
||||
return cookie.Value == formToken
|
||||
}
|
||||
|
||||
// ensureCSRFToken returns the existing CSRF token from the cookie, or generates a new one.
|
||||
func ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
token := generateCSRFToken()
|
||||
setCSRFCookie(w, token)
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
// Server handles the setup wizard HTTP routes.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
dataDir string
|
||||
logger *log.Logger
|
||||
tmpl *template.Template
|
||||
state *SetupState
|
||||
version string
|
||||
|
||||
// Scan state for async drive scanning
|
||||
scanMu sync.Mutex
|
||||
scanRunning bool
|
||||
scanResults []DriveBackup
|
||||
scanDone bool
|
||||
scanError string
|
||||
|
||||
// Restore progress
|
||||
restoreMu sync.Mutex
|
||||
restoreRunning bool
|
||||
restoreSteps []RestoreStep
|
||||
restoreError string
|
||||
restoreDone bool
|
||||
}
|
||||
|
||||
// RestoreStep tracks progress of a restore operation.
|
||||
type RestoreStep struct {
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "pending", "running", "done", "failed"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewServer creates a new setup wizard server.
|
||||
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
dataDir: dataDir,
|
||||
logger: logger,
|
||||
state: LoadState(dataDir),
|
||||
version: version,
|
||||
}
|
||||
s.loadTemplates()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) loadTemplates() {
|
||||
s.tmpl = template.Must(
|
||||
template.New("").Funcs(template.FuncMap{
|
||||
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
|
||||
}).ParseFS(templateFS, "templates/*.html"),
|
||||
)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for the setup wizard.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/", s.handleRoot)
|
||||
mux.HandleFunc("/setup", s.handleWelcome)
|
||||
mux.HandleFunc("/setup/scan", s.handleScan)
|
||||
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
||||
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
||||
mux.HandleFunc("/setup/restore", s.handleRestore)
|
||||
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||
mux.HandleFunc("/setup/manual", s.handleManual)
|
||||
mux.HandleFunc("/setup/failed", s.handleFailed)
|
||||
mux.HandleFunc("/static/style.css", s.handleCSS)
|
||||
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
domain := s.cfg.Customer.Domain
|
||||
ips := DetectLocalIPs()
|
||||
|
||||
var accessURLs []string
|
||||
if domain != "" {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
|
||||
}
|
||||
for _, ip := range ips {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"AccessURLs": accessURLs,
|
||||
"Version": s.version,
|
||||
}
|
||||
s.render(w, "setup_welcome", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
// Start scan if not already running
|
||||
s.scanMu.Lock()
|
||||
if !s.scanRunning && !s.scanDone {
|
||||
s.scanRunning = true
|
||||
go s.runDriveScan()
|
||||
}
|
||||
s.scanMu.Unlock()
|
||||
|
||||
s.state.SetStep("scan")
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_scan", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.scanMu.Lock()
|
||||
defer s.scanMu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"running": s.scanRunning,
|
||||
"done": s.scanDone,
|
||||
"results": s.scanResults,
|
||||
"error": s.scanError,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processHubRestore(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"CustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_hub_restore", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
source := r.FormValue("source")
|
||||
switch source {
|
||||
case "local":
|
||||
drivePath := r.FormValue("drive_path")
|
||||
go s.executeLocalRestore(drivePath)
|
||||
case "hub":
|
||||
go s.executeHubRestore()
|
||||
default:
|
||||
http.Error(w, "Invalid restore source", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_restore_exec", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"running": s.restoreRunning,
|
||||
"done": s.restoreDone,
|
||||
"steps": s.restoreSteps,
|
||||
"error": s.restoreError,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processFreshHub(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"CustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_fresh_hub", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processManual(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_failed", data)
|
||||
}
|
||||
|
||||
// --- Static Assets (reuse from web package embed) ---
|
||||
|
||||
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the main style.css from the web package templates
|
||||
cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css")
|
||||
data, err := os.ReadFile(cssPath)
|
||||
if err != nil {
|
||||
// Fallback: serve minimal CSS
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Write([]byte(minimalCSS))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
||||
logoPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "static", "felhom-logo.svg")
|
||||
data, err := os.ReadFile(logoPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// --- Processing Logic ---
|
||||
|
||||
func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.state.SetFormField("customer_id", customerID)
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
s.renderError(w, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID)
|
||||
return
|
||||
}
|
||||
|
||||
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
s.renderError(w, "setup_hub_restore", msg, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Store recovery data in state for restore execution
|
||||
s.state.SelectedBackup = &SelectedBackup{
|
||||
Source: "hub",
|
||||
CustomerID: customerID,
|
||||
}
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
||||
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
||||
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
||||
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
||||
}
|
||||
s.state.SetStep("restore-confirm")
|
||||
s.state.Save()
|
||||
|
||||
// Show confirmation page with backup details
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"CustomerID": customerID,
|
||||
"HasInfraBackup": recovery.HasInfraBackup,
|
||||
"HasConfig": recovery.ConfigYAML != "",
|
||||
"Source": "hub",
|
||||
}
|
||||
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
||||
data["Timestamp"] = recovery.InfraBackup.Timestamp
|
||||
data["StackCount"] = len(recovery.InfraBackup.DeployedStacks)
|
||||
}
|
||||
s.render(w, "setup_restore_exec", data)
|
||||
}
|
||||
|
||||
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.state.SetFormField("customer_id", customerID)
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
s.renderError(w, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID)
|
||||
return
|
||||
}
|
||||
|
||||
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
s.renderError(w, "setup_fresh_hub", msg, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
// Write config and finish setup
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
||||
s.renderError(w, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
||||
// Save all form fields
|
||||
fields := []string{"customer_id", "display_name", "domain", "email",
|
||||
"cf_tunnel_token", "cf_api_token", "system_data_path",
|
||||
"password", "password_confirm",
|
||||
"git_repo_url", "git_username", "git_token"}
|
||||
for _, f := range fields {
|
||||
s.state.SetFormField(f, r.FormValue(f))
|
||||
}
|
||||
|
||||
// Validate
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
password := r.FormValue("password")
|
||||
passwordConfirm := r.FormValue("password_confirm")
|
||||
|
||||
var errs []string
|
||||
if customerID == "" {
|
||||
errs = append(errs, "Ügyfél-azonosító kötelező")
|
||||
}
|
||||
if domain == "" || domain == "homeserver.local" {
|
||||
errs = append(errs, "Érvényes domain szükséges")
|
||||
}
|
||||
if password != "" && len(password) < 8 {
|
||||
errs = append(errs, "A jelszó legalább 8 karakter legyen")
|
||||
}
|
||||
if password != passwordConfirm {
|
||||
errs = append(errs, "A jelszavak nem egyeznek")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": errs,
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate controller.yaml
|
||||
configYAML := s.generateManualConfig()
|
||||
if err := s.writeFreshConfig(configYAML, ""); err != nil {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Restore Execution ---
|
||||
|
||||
func (s *Server) executeLocalRestore(drivePath string) {
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = true
|
||||
s.restoreDone = false
|
||||
s.restoreError = ""
|
||||
s.restoreSteps = []RestoreStep{
|
||||
{Label: "Mentés beolvasása...", Status: "running"},
|
||||
{Label: "Konfiguráció visszaállítása...", Status: "pending"},
|
||||
{Label: "Beállítás befejezése...", Status: "pending"},
|
||||
}
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
// Step 1: Read backup
|
||||
backupData, _, err := backup.ReadLocalInfraBackup(drivePath)
|
||||
if err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var ib report.InfraBackup
|
||||
if err := json.Unmarshal(backupData, &ib); err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err))
|
||||
return
|
||||
}
|
||||
s.setRestoreStepDone(0)
|
||||
|
||||
// Step 2: Write config files
|
||||
s.setRestoreStepRunning(1)
|
||||
if err := s.writeRestoredConfig(&ib); err != nil {
|
||||
s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
s.setRestoreStepDone(1)
|
||||
|
||||
// Step 3: Finalize
|
||||
s.setRestoreStepRunning(2)
|
||||
|
||||
// Save retrieval password from state if available
|
||||
retrievalPw := s.state.GetFormField("retrieval_password")
|
||||
if retrievalPw != "" {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err == nil {
|
||||
sett.SetRetrievalPassword(retrievalPw)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue DR event
|
||||
s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks))
|
||||
|
||||
s.setRestoreStepDone(2)
|
||||
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = false
|
||||
s.restoreDone = true
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath)
|
||||
|
||||
// Wait a moment for the UI to poll, then exit
|
||||
time.Sleep(2 * time.Second)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
func (s *Server) executeHubRestore() {
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = true
|
||||
s.restoreDone = false
|
||||
s.restoreError = ""
|
||||
s.restoreSteps = []RestoreStep{
|
||||
{Label: "Konfiguráció visszaállítása...", Status: "running"},
|
||||
{Label: "Beállítás befejezése...", Status: "pending"},
|
||||
}
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
// Get stored data from state
|
||||
configYAML := s.state.GetFormField("hub_config_yaml")
|
||||
ibJSON := s.state.GetFormField("hub_infra_backup")
|
||||
|
||||
// Write controller.yaml
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Restore settings from infra backup if available
|
||||
if ibJSON != "" {
|
||||
var ib report.InfraBackup
|
||||
if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil {
|
||||
s.restoreFromInfraBackup(&ib)
|
||||
}
|
||||
}
|
||||
s.setRestoreStepDone(0)
|
||||
|
||||
// Step 2: Finalize
|
||||
s.setRestoreStepRunning(1)
|
||||
|
||||
// Save retrieval password
|
||||
retrievalPw := s.state.GetFormField("retrieval_password")
|
||||
if retrievalPw != "" {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err == nil {
|
||||
sett.SetRetrievalPassword(retrievalPw)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue DR event
|
||||
stackCount := 0
|
||||
timestamp := ""
|
||||
if ibJSON != "" {
|
||||
var ib report.InfraBackup
|
||||
if json.Unmarshal([]byte(ibJSON), &ib) == nil {
|
||||
stackCount = len(ib.DeployedStacks)
|
||||
timestamp = ib.Timestamp
|
||||
}
|
||||
}
|
||||
s.queueDREvent("hub", timestamp, stackCount)
|
||||
|
||||
s.setRestoreStepDone(1)
|
||||
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = false
|
||||
s.restoreDone = true
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: Hub restore completed")
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Config Writing ---
|
||||
|
||||
func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error {
|
||||
// Decode and write controller.yaml
|
||||
if ib.ControllerConfigB64 != "" {
|
||||
configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding controller.yaml: %w", err)
|
||||
}
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, configData, 0600); err != nil {
|
||||
return fmt.Errorf("writing controller.yaml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.restoreFromInfraBackup(ib)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) {
|
||||
// Decode and write settings.json
|
||||
if ib.SettingsJSONB64 != "" {
|
||||
if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil {
|
||||
settingsPath := filepath.Join(s.dataDir, "settings.json")
|
||||
if err := atomicWriteFile(settingsPath, data, 0644); err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore restic password
|
||||
if ib.ResticPassword != "" {
|
||||
if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil {
|
||||
pwFile := "/opt/docker/felhom-controller/data/restic-password"
|
||||
if err := atomicWriteFile(pwFile, data, 0600); err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
return fmt.Errorf("writing controller.yaml: %w", err)
|
||||
}
|
||||
|
||||
// Create initial settings with password hash and retrieval password
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err != nil {
|
||||
sett = &settings.Settings{}
|
||||
}
|
||||
|
||||
// Hash the dashboard password if provided in form
|
||||
if pw := s.state.GetFormField("password"); pw != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
sett.SetPasswordHash(string(hash))
|
||||
}
|
||||
}
|
||||
|
||||
if retrievalPassword != "" {
|
||||
sett.SetRetrievalPassword(retrievalPassword)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) generateManualConfig() string {
|
||||
fd := s.state.FormData
|
||||
|
||||
customerID := fd["customer_id"]
|
||||
displayName := fd["display_name"]
|
||||
if displayName == "" {
|
||||
displayName = customerID
|
||||
}
|
||||
domain := fd["domain"]
|
||||
email := fd["email"]
|
||||
cfTunnelToken := fd["cf_tunnel_token"]
|
||||
cfAPIToken := fd["cf_api_token"]
|
||||
systemDataPath := fd["system_data_path"]
|
||||
if systemDataPath == "" {
|
||||
systemDataPath = "/mnt/sys_drive"
|
||||
}
|
||||
|
||||
// Generate session secret
|
||||
secretBytes := make([]byte, 32)
|
||||
crand.Read(secretBytes)
|
||||
sessionSecret := hex.EncodeToString(secretBytes)
|
||||
|
||||
// Generate password hash
|
||||
passwordHash := ""
|
||||
if pw := fd["password"]; pw != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
}
|
||||
|
||||
gitRepoURL := fd["git_repo_url"]
|
||||
if gitRepoURL == "" {
|
||||
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
||||
}
|
||||
gitUsername := fd["git_username"]
|
||||
gitToken := fd["git_token"]
|
||||
|
||||
// Build YAML manually (simple key-value, no templates needed)
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by felhom-controller setup wizard\n")
|
||||
b.WriteString("customer:\n")
|
||||
fmt.Fprintf(&b, " id: %q\n", customerID)
|
||||
fmt.Fprintf(&b, " name: %q\n", displayName)
|
||||
fmt.Fprintf(&b, " domain: %q\n", domain)
|
||||
if email != "" {
|
||||
fmt.Fprintf(&b, " email: %q\n", email)
|
||||
}
|
||||
b.WriteString("\ninfrastructure:\n")
|
||||
if cfTunnelToken != "" {
|
||||
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
|
||||
}
|
||||
if cfAPIToken != "" {
|
||||
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
|
||||
}
|
||||
b.WriteString("\npaths:\n")
|
||||
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
|
||||
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
|
||||
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
|
||||
b.WriteString("\nsystem:\n")
|
||||
b.WriteString(" reserved_memory_mb: 384\n")
|
||||
b.WriteString("\nweb:\n")
|
||||
b.WriteString(" listen: \":8080\"\n")
|
||||
b.WriteString(" setup_listen: \":8081\"\n")
|
||||
if passwordHash != "" {
|
||||
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
|
||||
}
|
||||
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
|
||||
b.WriteString("\ngit:\n")
|
||||
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
|
||||
b.WriteString(" branch: \"main\"\n")
|
||||
b.WriteString(" sync_interval: \"15m\"\n")
|
||||
if gitUsername != "" {
|
||||
fmt.Fprintf(&b, " username: %q\n", gitUsername)
|
||||
}
|
||||
if gitToken != "" {
|
||||
fmt.Fprintf(&b, " token: %q\n", gitToken)
|
||||
}
|
||||
b.WriteString("\nstacks:\n")
|
||||
b.WriteString(" protected:\n")
|
||||
b.WriteString(" - \"traefik\"\n")
|
||||
b.WriteString(" - \"cloudflared\"\n")
|
||||
b.WriteString(" - \"felhom-controller\"\n")
|
||||
b.WriteString(" - \"filebrowser\"\n")
|
||||
b.WriteString(" update_window: \"03:00-05:00\"\n")
|
||||
b.WriteString("\nbackup:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
|
||||
b.WriteString(" db_dump_schedule: \"02:30\"\n")
|
||||
b.WriteString(" restic_schedule: \"03:00\"\n")
|
||||
b.WriteString(" retention:\n")
|
||||
b.WriteString(" keep_daily: 7\n")
|
||||
b.WriteString(" keep_weekly: 4\n")
|
||||
b.WriteString(" keep_monthly: 6\n")
|
||||
b.WriteString(" prune_schedule: \"weekly\"\n")
|
||||
b.WriteString("\nmonitoring:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
|
||||
b.WriteString(" system_health_interval: \"5m\"\n")
|
||||
b.WriteString(" health_check_schedule: \"06:00\"\n")
|
||||
b.WriteString("\nhub:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
|
||||
// Generate a Hub API key from customer ID
|
||||
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
|
||||
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
|
||||
b.WriteString(" push_interval: \"15m\"\n")
|
||||
b.WriteString("\nself_update:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" check_interval: \"6h\"\n")
|
||||
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
|
||||
b.WriteString(" auto_update: false\n")
|
||||
b.WriteString(" health_timeout_seconds: 60\n")
|
||||
b.WriteString("\nlogging:\n")
|
||||
b.WriteString(" level: \"info\"\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (s *Server) finishSetup() {
|
||||
s.state.Remove()
|
||||
s.logger.Printf("[INFO] Setup complete — restarting controller")
|
||||
os.Exit(0) // Docker restart policy will restart us
|
||||
}
|
||||
|
||||
func (s *Server) queueDREvent(source, backupTimestamp string, stackCount int) {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
details, _ := json.Marshal(map[string]interface{}{
|
||||
"source": source,
|
||||
"backup_timestamp": backupTimestamp,
|
||||
"stacks_count": stackCount,
|
||||
"controller_version": s.version,
|
||||
})
|
||||
|
||||
sett.AddPendingEvent(settings.PendingEvent{
|
||||
EventType: "disaster_recovery_completed",
|
||||
Severity: "warning",
|
||||
Message: "System restored from backup",
|
||||
Details: string(details),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreStepDone(idx int) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "done"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreStepRunning(idx int) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "running"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreError(idx int, msg string) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "failed"
|
||||
s.restoreSteps[idx].Error = msg
|
||||
}
|
||||
s.restoreRunning = false
|
||||
s.restoreError = msg
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Printf("[ERROR] Template %s render error: %v", name, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderError(w http.ResponseWriter, tmpl, msg, customerID string) {
|
||||
csrf := ensureCSRFToken(w, nil)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Error": msg,
|
||||
"CustomerID": customerID,
|
||||
}
|
||||
s.render(w, tmpl, data)
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isError(err, target error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), target.Error())
|
||||
}
|
||||
|
||||
// Minimal CSS for when the main stylesheet can't be loaded
|
||||
const minimalCSS = `
|
||||
:root { --bg-primary: #0d1117; --bg-card: #1c2128; --text-primary: #e6edf3; --text-secondary: #8b949e; --accent-blue: #0088cc; --border: #30363d; --green: #238636; --red: #da3633; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header img { width: 120px; margin-bottom: 1rem; }
|
||||
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
|
||||
.setup-card:hover { border-color: var(--accent-blue); }
|
||||
.setup-card h3 { margin-bottom: 0.5rem; }
|
||||
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 0.9rem; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 6px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
|
||||
.btn-primary { background: var(--green); color: #fff; }
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
|
||||
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.alert-error { background: rgba(218,54,51,0.15); color: #f85149; border: 1px solid rgba(218,54,51,0.3); }
|
||||
.alert-info { background: rgba(0,136,204,0.15); color: #58a6ff; border: 1px solid rgba(0,136,204,0.3); }
|
||||
.info-box { background: rgba(0,136,204,0.1); border: 1px solid rgba(0,136,204,0.2); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
||||
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||
.badge-error { background: rgba(218,54,51,0.15); color: var(--red); }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.section { margin-bottom: 1.5rem; }
|
||||
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
|
||||
.step-list { list-style: none; }
|
||||
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
|
||||
.step-done { color: var(--green); }
|
||||
.step-running { color: var(--accent-blue); }
|
||||
.step-failed { color: var(--red); }
|
||||
`
|
||||
@@ -0,0 +1,49 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DetectLocalIPs returns non-loopback, non-docker IPv4 addresses.
|
||||
func DetectLocalIPs() []string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, iface := range ifaces {
|
||||
// Skip down, loopback, and Docker/container interfaces
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(iface.Name)
|
||||
if strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") ||
|
||||
strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "lo") {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
|
||||
continue // skip non-IPv4
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(ips)
|
||||
return ips
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// DriveBackup represents a found infra backup on a drive.
|
||||
type DriveBackup struct {
|
||||
Device string `json:"device"`
|
||||
Label string `json:"label"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CtrlVersion string `json:"controller_version"`
|
||||
IntegrityOK bool `json:"integrity_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
WasTempMounted bool `json:"-"`
|
||||
}
|
||||
|
||||
// lsblkOutput represents the JSON output of lsblk.
|
||||
type lsblkOutput struct {
|
||||
Blockdevices []lsblkDevice `json:"blockdevices"`
|
||||
}
|
||||
|
||||
type lsblkDevice struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
FSType *string `json:"fstype"`
|
||||
MountPoint *string `json:"mountpoint"`
|
||||
Label *string `json:"label"`
|
||||
Size interface{} `json:"size"` // string or int
|
||||
Type string `json:"type"` // "disk", "part"
|
||||
Children []lsblkDevice `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ScanDrivesForInfraBackups scans all block devices for .felhom-infra-backup/ directories.
|
||||
func ScanDrivesForInfraBackups(logger *log.Logger) ([]DriveBackup, error) {
|
||||
logger.Printf("[INFO] Setup: scanning drives for infra backups...")
|
||||
|
||||
// Read currently mounted filesystems
|
||||
mountedFS := readMountedFilesystems()
|
||||
|
||||
// Get root device to skip
|
||||
rootDevices := getRootDevices()
|
||||
|
||||
// Run lsblk
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,PATH,FSTYPE,MOUNTPOINT,LABEL,SIZE,TYPE").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lsblk failed: %w", err)
|
||||
}
|
||||
|
||||
var lsblk lsblkOutput
|
||||
if err := json.Unmarshal(out, &lsblk); err != nil {
|
||||
return nil, fmt.Errorf("parsing lsblk: %w", err)
|
||||
}
|
||||
|
||||
var results []DriveBackup
|
||||
|
||||
// Flatten all partitions
|
||||
var partitions []lsblkDevice
|
||||
for _, disk := range lsblk.Blockdevices {
|
||||
if disk.Type == "part" {
|
||||
partitions = append(partitions, disk)
|
||||
}
|
||||
for _, child := range disk.Children {
|
||||
if child.Type == "part" {
|
||||
partitions = append(partitions, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, part := range partitions {
|
||||
// Skip partitions without filesystem
|
||||
if part.FSType == nil || *part.FSType == "" || *part.FSType == "swap" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip LUKS encrypted partitions
|
||||
if *part.FSType == "crypto_LUKS" {
|
||||
logger.Printf("[DEBUG] Setup: skipping LUKS partition %s", part.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip LVM
|
||||
if part.Type == "lvm" {
|
||||
logger.Printf("[DEBUG] Setup: skipping LVM volume %s", part.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip root partitions
|
||||
if isRootPartition(part.Path, rootDevices) {
|
||||
continue
|
||||
}
|
||||
|
||||
result := scanPartition(part, mountedFS, logger)
|
||||
if result != nil {
|
||||
results = append(results, *result)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CleanupTempMounts unmounts any partitions that were temporarily mounted during scanning.
|
||||
func CleanupTempMounts(results []DriveBackup, logger *log.Logger) {
|
||||
for _, r := range results {
|
||||
if r.WasTempMounted && r.MountPoint != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
exec.CommandContext(ctx, "umount", r.MountPoint).Run()
|
||||
cancel()
|
||||
os.Remove(r.MountPoint)
|
||||
logger.Printf("[DEBUG] Setup: unmounted temp mount %s", r.MountPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup {
|
||||
label := ""
|
||||
if part.Label != nil {
|
||||
label = *part.Label
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
var mountPoint string
|
||||
var tempMounted bool
|
||||
|
||||
if part.MountPoint != nil && *part.MountPoint != "" {
|
||||
mountPoint = *part.MountPoint
|
||||
} else if mp, ok := mountedFS[part.Path]; ok {
|
||||
mountPoint = mp
|
||||
} else {
|
||||
// Try to mount temporarily
|
||||
tmpDir := filepath.Join("/mnt", ".felhom-scan", part.Name)
|
||||
if err := os.MkdirAll(tmpDir, 0700); err != nil {
|
||||
logger.Printf("[DEBUG] Setup: skip %s — cannot create temp dir: %v", part.Path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try read-only mount
|
||||
err := exec.CommandContext(ctx, "mount", "-o", "ro", part.Path, tmpDir).Run()
|
||||
if err != nil {
|
||||
// Retry with noload for journal errors
|
||||
err = exec.CommandContext(ctx, "mount", "-o", "ro,noload", part.Path, tmpDir).Run()
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmpDir)
|
||||
logger.Printf("[DEBUG] Setup: skip %s — mount failed: %v", part.Path, err)
|
||||
return nil
|
||||
}
|
||||
mountPoint = tmpDir
|
||||
tempMounted = true
|
||||
}
|
||||
|
||||
// Check for .felhom-infra-backup/
|
||||
infraDir := backup.InfraBackupDir(mountPoint)
|
||||
if _, err := os.Stat(infraDir); os.IsNotExist(err) {
|
||||
if tempMounted {
|
||||
exec.Command("umount", mountPoint).Run()
|
||||
os.Remove(mountPoint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Found backup — read and validate
|
||||
_, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
||||
|
||||
result := &DriveBackup{
|
||||
Device: part.Path,
|
||||
Label: label,
|
||||
MountPoint: mountPoint,
|
||||
WasTempMounted: tempMounted,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.IntegrityOK = false
|
||||
result.Error = err.Error()
|
||||
if meta != nil {
|
||||
result.CustomerID = meta.CustomerID
|
||||
result.Timestamp = meta.Timestamp
|
||||
result.CtrlVersion = meta.ControllerVersion
|
||||
}
|
||||
} else {
|
||||
result.IntegrityOK = true
|
||||
result.CustomerID = meta.CustomerID
|
||||
result.Timestamp = meta.Timestamp
|
||||
result.CtrlVersion = meta.ControllerVersion
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
||||
part.Path, label, result.CustomerID, result.IntegrityOK)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func readMountedFilesystems() map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) >= 2 {
|
||||
result[fields[0]] = fields[1]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getRootDevices() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
mountedFS := readMountedFilesystems()
|
||||
for dev, mp := range mountedFS {
|
||||
if mp == "/" || mp == "/boot" || mp == "/boot/efi" {
|
||||
result[dev] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isRootPartition(devPath string, rootDevices map[string]bool) bool {
|
||||
return rootDevices[devPath]
|
||||
}
|
||||
|
||||
func countValid(results []DriveBackup) int {
|
||||
n := 0
|
||||
for _, r := range results {
|
||||
if r.IntegrityOK {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runDriveScan runs the scan asynchronously and stores results on the Server.
|
||||
func (s *Server) runDriveScan() {
|
||||
results, err := ScanDrivesForInfraBackups(s.logger)
|
||||
|
||||
s.scanMu.Lock()
|
||||
defer s.scanMu.Unlock()
|
||||
|
||||
s.scanRunning = false
|
||||
s.scanDone = true
|
||||
if err != nil {
|
||||
s.scanError = err.Error()
|
||||
} else {
|
||||
s.scanResults = results
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// NeedsSetup checks whether the controller should enter setup mode.
|
||||
func NeedsSetup(cfg *config.Config) bool {
|
||||
return cfg.Customer.ID == "" || cfg.Customer.ID == "demo-felhom"
|
||||
}
|
||||
|
||||
// SetupState persists wizard progress to survive browser crashes.
|
||||
type SetupState struct {
|
||||
mu sync.Mutex `json:"-"`
|
||||
path string `json:"-"`
|
||||
|
||||
Step string `json:"step"` // "welcome", "scan", "hub-restore", "restore-exec", "fresh-hub", "fresh-manual", "done"
|
||||
Mode string `json:"mode"` // "restore" or "fresh"
|
||||
FormData map[string]string `json:"form_data"` // partially filled form fields
|
||||
|
||||
SelectedBackup *SelectedBackup `json:"selected_backup,omitempty"`
|
||||
}
|
||||
|
||||
// SelectedBackup tracks which backup the user chose.
|
||||
type SelectedBackup struct {
|
||||
Source string `json:"source"` // "local" or "hub"
|
||||
DrivePath string `json:"drive_path"` // for local
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// LoadState loads or creates setup state from the data directory.
|
||||
func LoadState(dataDir string) *SetupState {
|
||||
path := filepath.Join(dataDir, "setup-state.json")
|
||||
s := &SetupState{path: path, Step: "welcome"}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s // fresh state
|
||||
}
|
||||
if err := json.Unmarshal(data, s); err != nil {
|
||||
return &SetupState{path: path, Step: "welcome"}
|
||||
}
|
||||
s.path = path
|
||||
return s
|
||||
}
|
||||
|
||||
// Save persists the setup state atomically.
|
||||
func (s *SetupState) Save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.FormData == nil {
|
||||
s.FormData = make(map[string]string)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling setup state: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStep updates the current step and saves.
|
||||
func (s *SetupState) SetStep(step string) {
|
||||
s.mu.Lock()
|
||||
s.Step = step
|
||||
s.mu.Unlock()
|
||||
if err := s.Save(); err != nil {
|
||||
// Best effort — don't crash
|
||||
}
|
||||
}
|
||||
|
||||
// SetFormField saves a form field for state persistence.
|
||||
func (s *SetupState) SetFormField(key, value string) {
|
||||
s.mu.Lock()
|
||||
if s.FormData == nil {
|
||||
s.FormData = make(map[string]string)
|
||||
}
|
||||
s.FormData[key] = value
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetFormField retrieves a saved form field.
|
||||
func (s *SetupState) GetFormField(key string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.FormData == nil {
|
||||
return ""
|
||||
}
|
||||
return s.FormData[key]
|
||||
}
|
||||
|
||||
// Remove deletes the setup state file.
|
||||
func (s *SetupState) Remove() {
|
||||
os.Remove(s.path)
|
||||
}
|
||||
|
||||
// DefaultHubURL is the default Hub URL.
|
||||
const DefaultHubURL = "https://hub.felhom.eu"
|
||||
|
||||
// LogSetupMode logs the setup mode startup message.
|
||||
func LogSetupMode(domain string, ips []string, setupListen string, logger *log.Logger) {
|
||||
logger.Printf("[INFO] Controller in setup mode — waiting for configuration via web UI")
|
||||
if domain != "" {
|
||||
logger.Printf("[INFO] Setup wizard available at: https://felhom.%s", domain)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
logger.Printf("[INFO] Setup wizard available at: http://%s%s", ip, setupListen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "setup_failed"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visszaállítás sikertelen — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>A visszaállítás nem sikerült</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<p>Kérjük, vegye fel a kapcsolatot a támogatással:</p>
|
||||
<div style="margin-top: 1rem;">
|
||||
<p><strong>Email:</strong> <a href="mailto:support@felhom.eu" style="color: var(--accent-blue, #0088cc);">support@felhom.eu</a></p>
|
||||
<p><strong>Web:</strong> <a href="https://felhom.eu/kapcsolat" target="_blank" style="color: var(--accent-blue, #0088cc);">felhom.eu/kapcsolat</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem;">
|
||||
<a href="/setup/fresh" class="btn btn-primary">Új telepítés</a>
|
||||
<a href="/setup" class="btn btn-outline">Vissza a kezdőlapra</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "setup_fresh_hub"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Új telepítés — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Új telepítés</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Konfiguráció letöltése a Hub-ról.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="setup-card">
|
||||
<form method="POST" action="/setup/fresh">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Visszaállítási jelszó</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
required placeholder="A Hub-on beállított jelszó">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Letöltés</button>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/setup/manual" style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">Nincs Hub hozzáférés? Kézi beállítás →</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "setup_hub_restore"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hub visszaállítás — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás a Hub-ról</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Adja meg az ügyfél-azonosítót és jelszót a mentés letöltéséhez.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="setup-card">
|
||||
<form method="POST" action="/setup/hub-restore">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Visszaállítási jelszó</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
required placeholder="A Hub-on beállított jelszó">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Kapcsolódás</button>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,111 @@
|
||||
{{define "setup_manual"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kézi beállítás — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Kézi beállítás</h1>
|
||||
</div>
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-error">
|
||||
{{range .Errors}}<div>{{.}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/manual">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Ügyfél azonosítás</h3>
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító *</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{index .FormData "customer_id"}}" required placeholder="pl. kiscsalad-bp"
|
||||
pattern="[a-zA-Z0-9_-]+" title="Csak betűk, számok, kötőjel és aláhúzás">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="display_name">Megjelenítési név</label>
|
||||
<input type="text" id="display_name" name="display_name" class="form-control"
|
||||
value="{{index .FormData "display_name"}}" placeholder="pl. Kis Család">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="domain">Domain *</label>
|
||||
<input type="text" id="domain" name="domain" class="form-control"
|
||||
value="{{index .FormData "domain"}}" required placeholder="pl. kiscsalad.hu">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control"
|
||||
value="{{index .FormData "email"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Infrastruktúra</h3>
|
||||
<div class="form-group">
|
||||
<label for="cf_tunnel_token">Cloudflare Tunnel token</label>
|
||||
<input type="password" id="cf_tunnel_token" name="cf_tunnel_token" class="form-control"
|
||||
value="{{index .FormData "cf_tunnel_token"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf_api_token">Cloudflare API token</label>
|
||||
<input type="password" id="cf_api_token" name="cf_api_token" class="form-control"
|
||||
value="{{index .FormData "cf_api_token"}}" placeholder="Opcionális — DNS-01 TLS-hez">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="system_data_path">Rendszer adatpartíció útvonala</label>
|
||||
<input type="text" id="system_data_path" name="system_data_path" class="form-control"
|
||||
value="{{index .FormData "system_data_path"}}" placeholder="Alapértelmezett: /mnt/sys_drive">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Dashboard jelszó</h3>
|
||||
<div class="form-group">
|
||||
<label for="password">Jelszó (min. 8 karakter)</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
placeholder="Hagyja üresen, ha később szeretné beállítani" minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Jelszó megerősítés</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control"
|
||||
placeholder="Adja meg újra a jelszót">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Alkalmazás-katalógus</h3>
|
||||
<div class="form-group">
|
||||
<label for="git_repo_url">Git repo URL</label>
|
||||
<input type="text" id="git_repo_url" name="git_repo_url" class="form-control"
|
||||
value="{{index .FormData "git_repo_url"}}" placeholder="{{.DefaultGit}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="git_username">Git felhasználónév</label>
|
||||
<input type="text" id="git_username" name="git_username" class="form-control"
|
||||
value="{{index .FormData "git_username"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="git_token">Git token</label>
|
||||
<input type="password" id="git_token" name="git_token" class="form-control"
|
||||
value="{{index .FormData "git_token"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">Mentés és indítás</button>
|
||||
<a href="/setup/fresh" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,78 @@
|
||||
{{define "setup_restore_exec"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visszaállítás folyamatban — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<ul class="step-list" id="steps">
|
||||
<li><span class="spinner"></span> Indítás...</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="done-msg" style="display: none;">
|
||||
<div class="alert alert-info">Visszaállítás sikeres! A vezérlőpult újraindul...</div>
|
||||
</div>
|
||||
<div id="error-msg" style="display: none;">
|
||||
<div class="alert alert-error" id="error-text"></div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1rem;">
|
||||
<a href="/setup/failed" class="btn btn-outline">Tovább</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
function poll() {
|
||||
fetch('/setup/restore/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var list = document.getElementById('steps');
|
||||
if (data.steps && data.steps.length > 0) {
|
||||
list.innerHTML = '';
|
||||
data.steps.forEach(function(step) {
|
||||
var li = document.createElement('li');
|
||||
var icon = '';
|
||||
if (step.status === 'done') icon = '<span class="step-done">✓</span>';
|
||||
else if (step.status === 'running') icon = '<span class="spinner"></span>';
|
||||
else if (step.status === 'failed') icon = '<span class="step-failed">✗</span>';
|
||||
else icon = '<span style="color: var(--text-secondary);">○</span>';
|
||||
li.innerHTML = icon + ' ' + step.label;
|
||||
if (step.error) li.innerHTML += '<br><small style="color: var(--red, #f85149);">' + step.error + '</small>';
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
if (data.error) {
|
||||
document.getElementById('error-msg').style.display = 'block';
|
||||
document.getElementById('error-text').textContent = data.error;
|
||||
return;
|
||||
}
|
||||
if (data.done) {
|
||||
document.getElementById('done-msg').style.display = 'block';
|
||||
setTimeout(function() { window.location.href = '/'; }, 5000);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 1500);
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection lost — controller may be restarting
|
||||
document.getElementById('done-msg').style.display = 'block';
|
||||
setTimeout(function() { window.location.href = '/'; }, 5000);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,123 @@
|
||||
{{define "setup_scan"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meghajtók keresése — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás mentésből</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card" id="scan-status">
|
||||
<h3>Külső meghajtók keresése...</h3>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Ha vannak külső meghajtók csatlakoztatva a szerverhez, győződjön meg róla, hogy most csatlakoztatva vannak.</p>
|
||||
<div style="margin-top: 1rem; text-align: center;">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results" style="display: none;">
|
||||
<div class="setup-card">
|
||||
<h3>Találatok</h3>
|
||||
<table id="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Meghajtó</th>
|
||||
<th>Ügyfél</th>
|
||||
<th>Dátum</th>
|
||||
<th>Verzió</th>
|
||||
<th>Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<form method="POST" action="/setup/restore" id="restore-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="source" value="local">
|
||||
<input type="hidden" name="drive_path" id="selected-drive" value="">
|
||||
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
|
||||
</form>
|
||||
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-results" style="display: none;">
|
||||
<div class="setup-card">
|
||||
<h3>Nem található helyi mentés.</h3>
|
||||
<p style="color: var(--text-secondary, #8b949e);">A csatlakoztatott meghajtókon nem található Felhom infra mentés.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<a href="/setup/hub-restore" class="btn btn-primary">Tovább a Hub-hoz</a>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scan-error" style="display: none;">
|
||||
<div class="alert alert-error" id="scan-error-msg"></div>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var selectedDrive = '';
|
||||
function poll() {
|
||||
fetch('/setup/scan/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
document.getElementById('scan-error').style.display = 'block';
|
||||
document.getElementById('scan-error-msg').textContent = data.error;
|
||||
return;
|
||||
}
|
||||
if (!data.done) {
|
||||
setTimeout(poll, 1000);
|
||||
return;
|
||||
}
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
if (!data.results || data.results.length === 0) {
|
||||
document.getElementById('no-results').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('results').style.display = 'block';
|
||||
var tbody = document.querySelector('#results-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
var validCount = 0;
|
||||
data.results.forEach(function(r, i) {
|
||||
var tr = document.createElement('tr');
|
||||
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + r.mount_point + '" onclick="selectDrive(this)">' : '';
|
||||
tr.innerHTML = '<td>' + radio + '</td>' +
|
||||
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
|
||||
'<td>' + (r.customer_id || '-') + '</td>' +
|
||||
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + '</td>' +
|
||||
'<td>' + (r.controller_version || '-') + '</td>' +
|
||||
'<td>' + (r.integrity_ok ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>') + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
if (r.integrity_ok) validCount++;
|
||||
});
|
||||
if (validCount === 1) {
|
||||
var radio = tbody.querySelector('input[type="radio"]');
|
||||
if (radio) { radio.checked = true; selectDrive(radio); }
|
||||
}
|
||||
});
|
||||
}
|
||||
window.selectDrive = function(el) {
|
||||
document.getElementById('selected-drive').value = el.value;
|
||||
document.getElementById('restore-btn').disabled = false;
|
||||
};
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "setup_welcome"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Szerver Beállítás</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Felhom Szerver Beállítás</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">v{{.Version}}</p>
|
||||
</div>
|
||||
|
||||
{{if .AccessURLs}}
|
||||
<div class="info-box">
|
||||
Ez az oldal elérhető:
|
||||
{{range .AccessURLs}}<br>{{.}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
|
||||
<h3>Visszaállítás mentésből</h3>
|
||||
<p>Rendszerhiba utáni visszaállítás helyi meghajtóról vagy a Hub-ról. Válassza ezt, ha az operációs rendszert újratelepítette.</p>
|
||||
</a>
|
||||
|
||||
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
|
||||
<h3>Új telepítés</h3>
|
||||
<p>Új ügyfél beállítása. Konfiguráció letöltése a Hub-ról vagy kézi beállítás.</p>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user