refactor: pivot to JSON config — no yaml dependency, jq in bash, greenfield detection
This commit is contained in:
parent
386d3b3858
commit
b2e4dface2
8 changed files with 196 additions and 334 deletions
6
api.go
6
api.go
|
|
@ -79,7 +79,7 @@ func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// handleConfig handles configuration read/write.
|
// handleConfig handles configuration read/write.
|
||||||
// GET /api/config — return current config (without passwords)
|
// GET /api/config — return current config (without passwords)
|
||||||
// POST /api/config — save full config to config.yaml
|
// POST /api/config — save full config to config.json
|
||||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|
@ -126,9 +126,9 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
*s.appConfig = newCfg
|
*s.appConfig = newCfg
|
||||||
|
|
||||||
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.yaml"); err != nil {
|
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.json"); err != nil {
|
||||||
// Try saving to the app's config path.
|
// Try saving to the app's config path.
|
||||||
_ = SaveConfig(*s.appConfig, "config.yaml")
|
_ = SaveConfig(*s.appConfig, "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})
|
||||||
|
|
|
||||||
106
config.go
106
config.go
|
|
@ -1,75 +1,67 @@
|
||||||
// NextNVR — MIT License
|
// NextNVR — MIT License
|
||||||
// Copyright (c) 2026 NextNVR Contributors
|
// Copyright (c) 2026 NextNVR Contributors
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
// Handles config.yaml parsing with sensible defaults
|
// config.go — JSON configuration management. Zero external dependencies.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the top-level application configuration.
|
// Config represents the top-level application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server" json:"server"`
|
Server ServerConfig `json:"server"`
|
||||||
Storage StorageConfig `yaml:"storage" json:"storage"`
|
Storage StorageConfig `json:"storage"`
|
||||||
Auth AuthConfig `yaml:"auth" json:"auth"`
|
Auth AuthConfig `json:"auth"`
|
||||||
Cameras []CameraConfig `yaml:"cameras" json:"cameras"`
|
Cameras []CameraConfig `json:"cameras"`
|
||||||
Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"`
|
Go2RTC Go2RTCConfig `json:"go2rtc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds HTTP server settings.
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `yaml:"port" json:"port"`
|
Port string `json:"port"`
|
||||||
BindHost string `yaml:"bind_host" json:"bind_host"`
|
BindHost string `json:"bind_host"`
|
||||||
ViewerPort string `yaml:"viewer_port" json:"viewer_port"`
|
ViewerPort string `json:"viewer_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageConfig holds recording and retention settings.
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
RecordingsPath string `yaml:"recordings_path" json:"recordings_path"`
|
RecordingsPath string `json:"recordings_path"`
|
||||||
RetentionDays int `yaml:"retention_days" json:"retention_days"`
|
RetentionDays int `json:"retention_days"`
|
||||||
CleanupIntervalMins int `yaml:"cleanup_interval_mins" json:"cleanup_interval_mins"`
|
CleanupIntervalMins int `json:"cleanup_interval_mins"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CameraConfig represents a single camera's configuration.
|
|
||||||
type CameraConfig struct {
|
type CameraConfig struct {
|
||||||
ID string `yaml:"id" json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `yaml:"name" json:"name"`
|
Name string `json:"name"`
|
||||||
IP string `yaml:"ip" json:"ip"`
|
IP string `json:"ip"`
|
||||||
Username string `yaml:"username" json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `yaml:"password" json:"password"`
|
Password string `json:"password"`
|
||||||
ONVIFPort int `yaml:"onvif_port" json:"onvif_port,string"`
|
ONVIFPort int `json:"onvif_port,string"`
|
||||||
RTSPMain string `yaml:"rtsp_main" json:"rtsp_main"`
|
RTSPMain string `json:"rtsp_main"`
|
||||||
RTSPSub string `yaml:"rtsp_sub" json:"rtsp_sub"`
|
RTSPSub string `json:"rtsp_sub"`
|
||||||
Description string `yaml:"description" json:"description"`
|
Description string `json:"description"`
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Record bool `yaml:"record" json:"record"`
|
Record bool `json:"record"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go2RTCConfig holds go2rtc child process settings.
|
|
||||||
type Go2RTCConfig struct {
|
type Go2RTCConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Port string `yaml:"port" json:"port"`
|
Port string `json:"port"`
|
||||||
Binary string `yaml:"binary" json:"binary"`
|
Binary string `json:"binary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthConfig holds authentication settings.
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Master UserConfig `yaml:"master" json:"master"`
|
Master UserConfig `json:"master"`
|
||||||
Viewer UserConfig `yaml:"viewer" json:"viewer"`
|
Viewer UserConfig `json:"viewer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserConfig holds a single user's credentials.
|
|
||||||
type UserConfig struct {
|
type UserConfig struct {
|
||||||
Username string `yaml:"username" json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `yaml:"password" json:"password"` // bcrypt hash
|
Password string `json:"password"`
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIResponse wraps all JSON API responses.
|
// APIResponse wraps all JSON API responses.
|
||||||
|
|
@ -96,20 +88,23 @@ func DefaultConfig() Config {
|
||||||
ViewerPort: ":8090",
|
ViewerPort: ":8090",
|
||||||
},
|
},
|
||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
RecordingsPath: "/mnt/recordings",
|
RecordingsPath: "/home/master/nvr_data",
|
||||||
RetentionDays: 7,
|
RetentionDays: 7,
|
||||||
CleanupIntervalMins: 60,
|
CleanupIntervalMins: 60,
|
||||||
},
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Viewer: UserConfig{Enabled: true},
|
||||||
|
},
|
||||||
Go2RTC: Go2RTCConfig{
|
Go2RTC: Go2RTCConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Port: ":1984",
|
Port: ":1984",
|
||||||
Binary: "go2rtc",
|
Binary: "/opt/nextnvr/go2rtc",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig reads and parses a YAML configuration file.
|
// LoadConfig reads and parses a JSON configuration file.
|
||||||
// If the file does not exist, it returns DefaultConfig().
|
|
||||||
func LoadConfig(path string) (Config, error) {
|
func LoadConfig(path string) (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
|
@ -121,31 +116,22 @@ func LoadConfig(path string) (Config, error) {
|
||||||
return cfg, fmt.Errorf("reading config: %w", err)
|
return cfg, fmt.Errorf("reading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return cfg, fmt.Errorf("parsing config: %w", err)
|
return cfg, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply path defaults relative to config file location.
|
|
||||||
configDir := filepath.Dir(path)
|
|
||||||
if cfg.Storage.RecordingsPath == "" {
|
|
||||||
cfg.Storage.RecordingsPath = "/mnt/recordings"
|
|
||||||
}
|
|
||||||
_ = configDir // reserved for relative path resolution
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveConfig writes the configuration to a YAML file.
|
// SaveConfig writes the configuration to a JSON file.
|
||||||
func SaveConfig(cfg Config, path string) error {
|
func SaveConfig(cfg Config, path string) error {
|
||||||
data, err := yaml.Marshal(&cfg)
|
data, err := json.MarshalIndent(&cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshaling config: %w", err)
|
return fmt.Errorf("marshaling config: %w", err)
|
||||||
}
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n")
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
out := append(header, data...)
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, out, 0600); err != nil {
|
|
||||||
return fmt.Errorf("writing config: %w", err)
|
return fmt.Errorf("writing config: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
31
config.json.sample
Normal file
31
config.json.sample
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"port": ":8080",
|
||||||
|
"bind_host": "0.0.0.0",
|
||||||
|
"viewer_port": ":8090"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"recordings_path": "",
|
||||||
|
"retention_days": 7,
|
||||||
|
"cleanup_interval_mins": 60
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": false,
|
||||||
|
"master": {
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"viewer": {
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"go2rtc": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": ":1984",
|
||||||
|
"binary": "/opt/nextnvr/go2rtc"
|
||||||
|
},
|
||||||
|
"cameras": []
|
||||||
|
}
|
||||||
29
config.yaml
29
config.yaml
|
|
@ -1,29 +0,0 @@
|
||||||
# NextNVR Configuration
|
|
||||||
# Edit this file or use the web UI (Settings tab) to configure cameras.
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: ":8080"
|
|
||||||
bind_host: "0.0.0.0"
|
|
||||||
viewer_port: ":8090"
|
|
||||||
|
|
||||||
storage:
|
|
||||||
recordings_path: "/home/master/nvr_data"
|
|
||||||
retention_days: 7
|
|
||||||
cleanup_interval_mins: 60
|
|
||||||
|
|
||||||
auth:
|
|
||||||
enabled: false
|
|
||||||
master:
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
viewer:
|
|
||||||
enabled: true
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
|
|
||||||
go2rtc:
|
|
||||||
enabled: false
|
|
||||||
port: ":1984"
|
|
||||||
binary: "go2rtc"
|
|
||||||
|
|
||||||
cameras: []
|
|
||||||
337
deploy_nvr.sh
337
deploy_nvr.sh
|
|
@ -3,8 +3,8 @@
|
||||||
# Usage: curl -sSLO https://git.lohmar.co.uk/cclohmar/NextNVR/raw/branch/main/deploy_nvr.sh
|
# Usage: curl -sSLO https://git.lohmar.co.uk/cclohmar/NextNVR/raw/branch/main/deploy_nvr.sh
|
||||||
# chmod +x deploy_nvr.sh && ./deploy_nvr.sh
|
# chmod +x deploy_nvr.sh && ./deploy_nvr.sh
|
||||||
#
|
#
|
||||||
# Supports: Debian, Ubuntu, Fedora, RHEL, Rocky, Alma, Arch, Alpine
|
# Greenfield: clones repo, prompts for storage, builds config, installs service.
|
||||||
# Requires: curl, sudo, systemd (or falls back to nohup for non-systemd)
|
# Update: kills old instance, pulls latest, rebuilds, restarts (config preserved).
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git"
|
GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git"
|
||||||
|
|
@ -12,16 +12,11 @@ DEPLOY_DIR="/opt/nextnvr"
|
||||||
SERVICE_NAME="nextnvr"
|
SERVICE_NAME="nextnvr"
|
||||||
GO_VERSION="1.24.5"
|
GO_VERSION="1.24.5"
|
||||||
GO_ARCH="amd64"
|
GO_ARCH="amd64"
|
||||||
|
GO2RTC_VER="1.9.14"
|
||||||
|
|
||||||
# Detect ARM: use arm64 if running on aarch64.
|
[ "$(uname -m)" = "aarch64" ] && GO_ARCH="arm64"
|
||||||
if [ "$(uname -m)" = "aarch64" ]; then GO_ARCH="arm64"; fi
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
log() { echo -e "${GREEN}[NextNVR]${NC} $1"; }
|
log() { echo -e "${GREEN}[NextNVR]${NC} $1"; }
|
||||||
warn() { echo -e "${YELLOW}[NextNVR]${NC} $1"; }
|
warn() { echo -e "${YELLOW}[NextNVR]${NC} $1"; }
|
||||||
err() { echo -e "${RED}[NextNVR]${NC} $1"; }
|
err() { echo -e "${RED}[NextNVR]${NC} $1"; }
|
||||||
|
|
@ -29,30 +24,14 @@ info() { echo -e "${CYAN}[NextNVR]${NC} $1"; }
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " NextNVR — Portable Deployment"
|
echo " NextNVR — Deploy / Update"
|
||||||
echo " $(uname -m) / $(uname -s)"
|
echo " $(uname -m) / $(uname -s)"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── 0. Stop existing service (clean update) ──
|
# ── 0. Check sudo ──
|
||||||
if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
|
||||||
log "Stopping existing NextNVR service..."
|
|
||||||
sudo systemctl stop "${SERVICE_NAME}"
|
|
||||||
log "Service stopped."
|
|
||||||
elif pgrep -f "/opt/nextnvr/nextnvr" > /dev/null 2>&1; then
|
|
||||||
warn "NextNVR is running outside systemd. Stopping..."
|
|
||||||
sudo pkill -f "/opt/nextnvr/nextnvr" 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up any lingering processes.
|
|
||||||
sudo pkill -f "go2rtc" 2>/dev/null || true
|
|
||||||
sudo pkill -f "ffmpeg.*rec_" 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Check for sudo ──
|
|
||||||
if ! command -v sudo &>/dev/null; then
|
if ! command -v sudo &>/dev/null; then
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
# Running as root — create a no-op sudo.
|
|
||||||
sudo() { "$@"; }
|
sudo() { "$@"; }
|
||||||
else
|
else
|
||||||
err "sudo is required. Install it first or run as root."
|
err "sudo is required. Install it first or run as root."
|
||||||
|
|
@ -60,207 +39,134 @@ if ! command -v sudo &>/dev/null; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 0. Detect distro & package manager ──
|
# ── 1. Kill all existing instances ──
|
||||||
detect_pkg_manager() {
|
log "Stopping any running NextNVR instances..."
|
||||||
if command -v apt-get &>/dev/null; then
|
sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
|
||||||
PKG_INSTALL="sudo apt-get update -qq && sudo apt-get install -y -qq"
|
sudo pkill -9 -f "nextnvr" 2>/dev/null || true
|
||||||
PKG_SEARCH="apt-cache search"
|
sudo pkill -9 -f "go2rtc" 2>/dev/null || true
|
||||||
FFMPEG_PKG="ffmpeg"
|
sudo pkill -9 -f "ffmpeg.*rec_" 2>/dev/null || true
|
||||||
GIT_PKG="git"
|
sleep 2
|
||||||
CURL_PKG="curl"
|
|
||||||
DISTRO="debian"
|
|
||||||
elif command -v dnf &>/dev/null; then
|
|
||||||
PKG_INSTALL="sudo dnf install -y -q"
|
|
||||||
PKG_SEARCH="dnf search"
|
|
||||||
FFMPEG_PKG="ffmpeg-free"
|
|
||||||
GIT_PKG="git"
|
|
||||||
CURL_PKG="curl"
|
|
||||||
DISTRO="fedora"
|
|
||||||
elif command -v yum &>/dev/null; then
|
|
||||||
PKG_INSTALL="sudo yum install -y -q"
|
|
||||||
PKG_SEARCH="yum search"
|
|
||||||
FFMPEG_PKG="ffmpeg"
|
|
||||||
GIT_PKG="git"
|
|
||||||
CURL_PKG="curl"
|
|
||||||
DISTRO="rhel"
|
|
||||||
elif command -v pacman &>/dev/null; then
|
|
||||||
PKG_INSTALL="sudo pacman -S --noconfirm --needed"
|
|
||||||
PKG_SEARCH="pacman -Ss"
|
|
||||||
FFMPEG_PKG="ffmpeg"
|
|
||||||
GIT_PKG="git"
|
|
||||||
CURL_PKG="curl"
|
|
||||||
DISTRO="arch"
|
|
||||||
elif command -v apk &>/dev/null; then
|
|
||||||
PKG_INSTALL="sudo apk add --no-cache"
|
|
||||||
PKG_SEARCH="apk search"
|
|
||||||
FFMPEG_PKG="ffmpeg"
|
|
||||||
GIT_PKG="git"
|
|
||||||
CURL_PKG="curl"
|
|
||||||
DISTRO="alpine"
|
|
||||||
else
|
|
||||||
err "Could not detect package manager. Supported: apt, dnf, yum, pacman, apk."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
log "Detected: ${DISTRO} ($(echo ${PKG_INSTALL} | awk '{print $2}'))"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if pgrep -f "nextnvr" > /dev/null 2>&1; then
|
||||||
|
err "Cannot kill existing NextNVR processes. Please stop them manually and retry."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "All instances stopped."
|
||||||
|
|
||||||
|
# ── 2. Detect greenfield vs update ──
|
||||||
|
GREENFIELD=false
|
||||||
|
if [ ! -d "${DEPLOY_DIR}" ]; then
|
||||||
|
GREENFIELD=true
|
||||||
|
log "Greenfield deployment detected."
|
||||||
|
else
|
||||||
|
log "Existing installation detected — update mode (config preserved)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. System dependencies ──
|
||||||
|
detect_pkg_manager() {
|
||||||
|
if command -v apt-get &>/dev/null; then PKG="sudo apt-get install -y -qq"; DISTRO="debian"
|
||||||
|
elif command -v dnf &>/dev/null; then PKG="sudo dnf install -y -q"; DISTRO="fedora"
|
||||||
|
elif command -v yum &>/dev/null; then PKG="sudo yum install -y -q"; DISTRO="rhel"
|
||||||
|
elif command -v pacman &>/dev/null; then PKG="sudo pacman -S --noconfirm --needed"; DISTRO="arch"
|
||||||
|
elif command -v apk &>/dev/null; then PKG="sudo apk add --no-cache"; DISTRO="alpine"
|
||||||
|
else err "Unsupported distro."; exit 1; fi
|
||||||
|
log "Detected: ${DISTRO}"
|
||||||
|
}
|
||||||
detect_pkg_manager
|
detect_pkg_manager
|
||||||
|
|
||||||
# ── 1. Install system dependencies ──
|
|
||||||
log "Step 1/6: Installing system dependencies..."
|
|
||||||
|
|
||||||
if ! command -v ffmpeg &>/dev/null; then
|
if ! command -v ffmpeg &>/dev/null; then
|
||||||
info "Installing ffmpeg (${FFMPEG_PKG})..."
|
info "Installing ffmpeg..."; eval "${PKG} ffmpeg"; log "ffmpeg installed."
|
||||||
eval "${PKG_INSTALL} ${FFMPEG_PKG}"
|
fi
|
||||||
log "ffmpeg installed."
|
if ! command -v git &>/dev/null; then
|
||||||
else
|
info "Installing git..."; eval "${PKG} git"
|
||||||
log "ffmpeg already installed: $(ffmpeg -version 2>&1 | head -1)"
|
fi
|
||||||
|
if ! command -v curl &>/dev/null; then
|
||||||
|
info "Installing curl..."; eval "${PKG} curl"
|
||||||
|
fi
|
||||||
|
if ! command -v jq &>/dev/null; then
|
||||||
|
info "Installing jq..."; eval "${PKG} jq"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for pkg in git curl; do
|
# ── 4. Time sync ──
|
||||||
pkgvar="$(echo ${pkg} | tr '[:lower:]' '[:upper:]')_PKG"
|
|
||||||
if ! command -v ${pkg} &>/dev/null; then
|
|
||||||
info "Installing ${pkg}..."
|
|
||||||
eval "${PKG_INSTALL} ${!pkgvar}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# ── 1b. Time synchronization ──
|
|
||||||
log "Checking time sync..."
|
|
||||||
|
|
||||||
# Try timedatectl (systemd). Fall back to checking ntp/chrony.
|
|
||||||
TIME_SYNC_OK=false
|
|
||||||
if command -v timedatectl &>/dev/null; then
|
if command -v timedatectl &>/dev/null; then
|
||||||
if timedatectl status 2>/dev/null | grep -q "System clock synchronized: yes"; then
|
|
||||||
TIME_SYNC_OK=true
|
|
||||||
else
|
|
||||||
info "Enabling NTP via timedatectl..."
|
|
||||||
sudo timedatectl set-ntp true 2>/dev/null || true
|
sudo timedatectl set-ntp true 2>/dev/null || true
|
||||||
TIME_SYNC_OK=true
|
|
||||||
fi
|
|
||||||
elif systemctl is-active --quiet chronyd 2>/dev/null; then
|
|
||||||
TIME_SYNC_OK=true
|
|
||||||
elif systemctl is-active --quiet ntpd 2>/dev/null; then
|
|
||||||
TIME_SYNC_OK=true
|
|
||||||
else
|
|
||||||
warn "No time sync service detected. Trying to install chrony..."
|
|
||||||
eval "${PKG_INSTALL} chrony 2>/dev/null" || eval "${PKG_INSTALL} chronyd 2>/dev/null" || true
|
|
||||||
sudo systemctl enable --now chronyd 2>/dev/null || sudo systemctl enable --now chrony 2>/dev/null || true
|
|
||||||
TIME_SYNC_OK=true
|
|
||||||
fi
|
fi
|
||||||
|
log "Time sync: OK | $(date)"
|
||||||
|
|
||||||
if $TIME_SYNC_OK; then
|
# ── 5. Go ──
|
||||||
log "Time sync: OK"
|
|
||||||
else
|
|
||||||
warn "Time sync could not be verified. Timestamps may drift."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Local time: $(date)"
|
|
||||||
TZ=$(timedatectl show --property=Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "unknown")
|
|
||||||
log "Timezone: ${TZ}"
|
|
||||||
|
|
||||||
# ── 2. Install Go (portable — no distro packages) ──
|
|
||||||
log "Step 2/6: Setting up Go ${GO_VERSION}..."
|
|
||||||
|
|
||||||
GO_TAR="go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
GO_TAR="go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
|
||||||
GO_URL="https://go.dev/dl/${GO_TAR}"
|
|
||||||
GO_HOME="$HOME/.local/go"
|
GO_HOME="$HOME/.local/go"
|
||||||
|
if [ ! -f "${GO_HOME}/bin/go" ]; then
|
||||||
if [ -f "${GO_HOME}/bin/go" ]; then
|
info "Installing Go ${GO_VERSION}..."
|
||||||
log "Go already installed: $(${GO_HOME}/bin/go version)"
|
curl -sSL "https://go.dev/dl/${GO_TAR}" -o /tmp/${GO_TAR}
|
||||||
else
|
|
||||||
info "Downloading Go ${GO_VERSION} for linux/${GO_ARCH}..."
|
|
||||||
curl -sSL "${GO_URL}" -o /tmp/${GO_TAR}
|
|
||||||
mkdir -p "${GO_HOME}"
|
mkdir -p "${GO_HOME}"
|
||||||
tar -C "$HOME/.local" -xzf /tmp/${GO_TAR}
|
tar -C "$HOME/.local" -xzf /tmp/${GO_TAR}
|
||||||
rm /tmp/${GO_TAR}
|
rm /tmp/${GO_TAR}
|
||||||
log "Go ${GO_VERSION} installed."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export PATH="${GO_HOME}/bin:$PATH"
|
export PATH="${GO_HOME}/bin:$PATH"
|
||||||
export GOPATH="$HOME/go"
|
export GOPATH="$HOME/go"
|
||||||
export GOCACHE="$HOME/.cache/go-build"
|
log "Go: $(${GO_HOME}/bin/go version)"
|
||||||
|
|
||||||
# ── 3. Clone or update repository ──
|
# ── 6. Clone / pull ──
|
||||||
log "Step 3/6: Fetching NextNVR source..."
|
if $GREENFIELD; then
|
||||||
|
sudo mkdir -p "${DEPLOY_DIR}"
|
||||||
|
sudo chown "$(whoami):$(id -gn)" "${DEPLOY_DIR}"
|
||||||
|
git clone "${GIT_URL}" "${DEPLOY_DIR}"
|
||||||
|
log "Repository cloned."
|
||||||
|
else
|
||||||
|
git -C "${DEPLOY_DIR}" pull origin main
|
||||||
|
log "Repository updated."
|
||||||
|
fi
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
# Download go2rtc (required for live streaming and snapshots).
|
# ── 7. go2rtc ──
|
||||||
GO2RTC_VER="1.9.14"
|
|
||||||
GO2RTC_BIN="${DEPLOY_DIR}/go2rtc"
|
GO2RTC_BIN="${DEPLOY_DIR}/go2rtc"
|
||||||
if [ ! -f "${GO2RTC_BIN}" ]; then
|
if [ ! -f "${GO2RTC_BIN}" ]; then
|
||||||
info "Downloading go2rtc ${GO2RTC_VER}..."
|
info "Downloading go2rtc ${GO2RTC_VER}..."
|
||||||
sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
|
|
||||||
curl -sSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VER}/go2rtc_linux_${GO_ARCH}" -o "${GO2RTC_BIN}"
|
curl -sSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VER}/go2rtc_linux_${GO_ARCH}" -o "${GO2RTC_BIN}"
|
||||||
chmod +x "${GO2RTC_BIN}"
|
chmod +x "${GO2RTC_BIN}"
|
||||||
log "go2rtc installed."
|
log "go2rtc installed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d "${DEPLOY_DIR}/.git" ]; then
|
# ── 8. Configuration (greenfield only) ──
|
||||||
info "Repository exists, pulling latest..."
|
CONFIG_FILE="${DEPLOY_DIR}/config.json"
|
||||||
git -C "${DEPLOY_DIR}" pull origin main
|
if $GREENFIELD; then
|
||||||
log "Source updated."
|
|
||||||
else
|
|
||||||
info "Cloning repository..."
|
|
||||||
sudo mkdir -p "${DEPLOY_DIR}"
|
|
||||||
sudo chown "$(whoami):$(id -gn)" "${DEPLOY_DIR}"
|
|
||||||
git clone "${GIT_URL}" "${DEPLOY_DIR}"
|
|
||||||
log "Source cloned."
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "${DEPLOY_DIR}"
|
|
||||||
|
|
||||||
# ── 4. Build ──
|
|
||||||
log "Step 4/6: Building NextNVR binary..."
|
|
||||||
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
|
||||||
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
|
|
||||||
CGO_ENABLED=0 go build \
|
|
||||||
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \
|
|
||||||
-o nextnvr .
|
|
||||||
|
|
||||||
chmod +x nextnvr
|
|
||||||
log "Binary built: nextnvr v${VERSION} ($(du -h nextnvr | cut -f1))"
|
|
||||||
|
|
||||||
# ── 5. Configuration & directories ──
|
|
||||||
log "Step 5/6: Configuration..."
|
|
||||||
|
|
||||||
# Repo includes a default config.yaml. Check if storage path needs setting.
|
|
||||||
CURRENT_PATH=$(grep recordings_path "${DEPLOY_DIR}/config.yaml" 2>/dev/null | awk -F'"' '{print $2}')
|
|
||||||
if [ -z "${CURRENT_PATH}" ] || [ "${CURRENT_PATH}" = "/home/master/nvr_data" ]; then
|
|
||||||
echo ""
|
echo ""
|
||||||
info "Storage path not set. Where should recordings be stored?"
|
info "Where should recordings be stored?"
|
||||||
|
info " Examples: /mnt/recordings, /home/user/nvr_data, /media/usb"
|
||||||
if [ -e /dev/tty ]; then
|
if [ -e /dev/tty ]; then
|
||||||
read -p " Path [${HOME}/nvr_data]: " STORAGE_PATH </dev/tty
|
read -p " Storage path [${HOME}/nvr_data]: " STORAGE_PATH </dev/tty
|
||||||
fi
|
fi
|
||||||
STORAGE_PATH="${STORAGE_PATH:-${HOME}/nvr_data}"
|
STORAGE_PATH="${STORAGE_PATH:-${HOME}/nvr_data}"
|
||||||
STORAGE_PATH="${STORAGE_PATH%/}"
|
STORAGE_PATH="${STORAGE_PATH%/}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Build config.json from sample.
|
||||||
|
jq --arg path "${STORAGE_PATH}" \
|
||||||
|
--arg go2rtc "${GO2RTC_BIN}" \
|
||||||
|
'.storage.recordings_path = $path | .go2rtc.binary = $go2rtc' \
|
||||||
|
config.json.sample > "${CONFIG_FILE}"
|
||||||
|
|
||||||
sudo mkdir -p "${STORAGE_PATH}"
|
sudo mkdir -p "${STORAGE_PATH}"
|
||||||
sudo chown "$(whoami):$(id -gn)" "${STORAGE_PATH}"
|
sudo chown "$(whoami):$(id -gn)" "${STORAGE_PATH}"
|
||||||
sed -i "s|recordings_path:.*|recordings_path: \"${STORAGE_PATH}\"|" "${DEPLOY_DIR}/config.yaml"
|
log "Config created: ${CONFIG_FILE}"
|
||||||
# Set go2rtc binary path.
|
log "Storage path: ${STORAGE_PATH}"
|
||||||
sed -i "s|binary:.*|binary: \"${GO2RTC_BIN}\"|" "${DEPLOY_DIR}/config.yaml"
|
|
||||||
log "Storage: ${STORAGE_PATH}"
|
|
||||||
else
|
else
|
||||||
log "Storage: ${CURRENT_PATH}"
|
log "Config preserved: ${CONFIG_FILE}"
|
||||||
sudo mkdir -p "${CURRENT_PATH}" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 6. Install service ──
|
# ── 9. Build ──
|
||||||
log "Step 6/6: Service installation..."
|
log "Building NextNVR..."
|
||||||
|
go mod tidy 2>/dev/null || true
|
||||||
|
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||||
|
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o nextnvr .
|
||||||
|
chmod +x nextnvr
|
||||||
|
log "Binary: nextnvr v${VERSION} ($(du -h nextnvr | cut -f1))"
|
||||||
|
|
||||||
RUN_USER="$(whoami)"
|
# ── 10. Systemd service ──
|
||||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
HAS_SYSTEMD=false
|
sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
|
||||||
if command -v systemctl &>/dev/null; then
|
|
||||||
HAS_SYSTEMD=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $HAS_SYSTEMD; then
|
|
||||||
sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NextNVR — IP Camera Recorder
|
Description=NextNVR — IP Camera Recorder
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
@ -268,9 +174,9 @@ Wants=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=${RUN_USER}
|
User=$(whoami)
|
||||||
WorkingDirectory=${DEPLOY_DIR}
|
WorkingDirectory=${DEPLOY_DIR}
|
||||||
ExecStart=${DEPLOY_DIR}/nextnvr --config ${DEPLOY_DIR}/config.yaml
|
ExecStart=${DEPLOY_DIR}/nextnvr --config ${CONFIG_FILE}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=65536
|
||||||
|
|
@ -281,31 +187,10 @@ StandardError=journal
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable "${SERVICE_NAME}"
|
sudo systemctl enable "${SERVICE_NAME}"
|
||||||
sudo systemctl restart "${SERVICE_NAME}"
|
sudo systemctl restart "${SERVICE_NAME}"
|
||||||
|
sleep 2
|
||||||
sleep 2
|
|
||||||
echo ""
|
|
||||||
sudo systemctl status "${SERVICE_NAME}" --no-pager --lines=5 2>/dev/null || true
|
|
||||||
else
|
|
||||||
warn "systemd not detected. Running NextNVR in background with nohup."
|
|
||||||
|
|
||||||
# Kill any existing instance.
|
|
||||||
pkill -f "nextnvr --config" 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
nohup "${DEPLOY_DIR}/nextnvr" --config "${DEPLOY_DIR}/config.yaml" \
|
|
||||||
> "${DEPLOY_DIR}/nextnvr.log" 2>&1 &
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
if pgrep -f "nextnvr --config" > /dev/null; then
|
|
||||||
log "NextNVR started (PID $(pgrep -f 'nextnvr --config' | head -1))."
|
|
||||||
info "Logs: tail -f ${DEPLOY_DIR}/nextnvr.log"
|
|
||||||
else
|
|
||||||
err "NextNVR failed to start. Check: ${DEPLOY_DIR}/nextnvr.log"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Done ──
|
# ── Done ──
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -314,18 +199,14 @@ echo -e " ${GREEN}NextNVR deployment complete!${NC}"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo ""
|
echo ""
|
||||||
info " Web UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):8080"
|
info " Web UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):8080"
|
||||||
info " Config: ${DEPLOY_DIR}/config.yaml"
|
info " Config: ${CONFIG_FILE}"
|
||||||
info " Storage: $(grep recordings_path ${DEPLOY_DIR}/config.yaml 2>/dev/null | awk '{print $2}' | tr -d '"')"
|
info " Storage: $(jq -r .storage.recordings_path ${CONFIG_FILE} 2>/dev/null || echo 'unknown')"
|
||||||
echo ""
|
echo ""
|
||||||
if $HAS_SYSTEMD; then
|
echo " Service:"
|
||||||
echo " Service:"
|
echo " sudo systemctl status ${SERVICE_NAME}"
|
||||||
echo " sudo systemctl status ${SERVICE_NAME}"
|
echo " sudo systemctl restart ${SERVICE_NAME}"
|
||||||
echo " sudo systemctl restart ${SERVICE_NAME}"
|
echo " sudo journalctl -u ${SERVICE_NAME} -f"
|
||||||
echo " sudo journalctl -u ${SERVICE_NAME} -f"
|
echo ""
|
||||||
else
|
|
||||||
echo " Process:"
|
sudo systemctl status "${SERVICE_NAME}" --no-pager --lines=5 2>/dev/null || true
|
||||||
echo " ps aux | grep nextnvr"
|
|
||||||
echo " tail -f ${DEPLOY_DIR}/nextnvr.log"
|
|
||||||
echo " To stop: pkill -f 'nextnvr --config'"
|
|
||||||
fi
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
||||||
5
go.mod
5
go.mod
|
|
@ -2,7 +2,4 @@ module github.com/cclohmar/NextNVR
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/crypto v0.54.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,6 +1,2 @@
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|
|
||||||
10
main.go
10
main.go
|
|
@ -136,13 +136,13 @@ func flagConfigPath() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: look next to the binary, then /opt/nextnvr.
|
// Default: look next to the binary, then /opt/nextnvr.
|
||||||
if _, err := os.Stat("config.yaml"); err == nil {
|
if _, err := os.Stat("config.json"); err == nil {
|
||||||
return "config.yaml"
|
return "config.json"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat("/opt/nextnvr/config.yaml"); err == nil {
|
if _, err := os.Stat("/opt/nextnvr/config.json"); err == nil {
|
||||||
return "/opt/nextnvr/config.yaml"
|
return "/opt/nextnvr/config.json"
|
||||||
}
|
}
|
||||||
return "config.yaml"
|
return "config.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
// App holds the application runtime state.
|
// App holds the application runtime state.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue