Initial npmctl: NPM CLI + generic API client
Some checks failed
release / build-release (push) Failing after 4m23s

Rust CLI for Nginx Proxy Manager via its API, with sudo-like token auth
(login prompts for password, caches only a short-lived token, never the
password) and a generic `api` command to talk to any HTTP API.

Commands: login/logout/status, list-hosts/get-host/find, add-proxy,
enable/disable/delete-host, list-certs/add-cert/renew-cert, dump (JSON
backup), apply (declarative), and generic api (method/url/header/data/
auth-key/insecure).

Includes clap help + help-on-bad-input, Gitea Actions release workflow
(arm64 native + x64 cross), VS Code build task (-> ./build), README and
MIT license.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPrEZVBpPVoCRyJz2exkZ
This commit is contained in:
brasse
2026-07-04 13:06:35 +02:00
commit b1154aea9d
13 changed files with 3099 additions and 0 deletions

215
src/cli.rs Normal file
View File

@@ -0,0 +1,215 @@
//! Kommandoradsgränssnitt (clap).
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
/// npmctl — styr Nginx Proxy Manager via dess API, plus en generisk
/// `api`-funktion för att prata med vilket HTTP-API som helst.
///
/// Autentisering fungerar som `sudo`: kör `npmctl login` en gång och skriv
/// lösenordet i prompten (det når aldrig loggar/argv). En kortlivad token
/// cachas sedan lokalt (0600) och används för efterföljande anrop tills den
/// går ut — då ombeds du logga in igen.
#[derive(Parser, Debug)]
#[command(
name = "npmctl",
version,
about = "CLI för Nginx Proxy Manager + generisk API-klient",
long_about = None,
propagate_version = true,
arg_required_else_help = true
)]
pub struct Cli {
/// Bas-URL till NPM (t.ex. http://127.0.0.1:81). Kan även sättas via
/// NPMCTL_BASE_URL eller i konfigfilen. Standard: http://127.0.0.1:81
#[arg(long, global = true, env = "NPMCTL_BASE_URL")]
pub base_url: Option<String>,
/// Alternativ sökväg till konfig-/sessionskatalog.
#[arg(long, global = true)]
pub config_dir: Option<PathBuf>,
/// Mer utförlig utskrift (visar HTTP-status m.m.).
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
/// Logga in mot NPM och cacha en token (frågar efter lösenord).
Login(LoginArgs),
/// Radera den cachade sessionen.
Logout,
/// Visa inloggningsstatus, token-utgång och bas-URL.
Status,
/// Lista alla proxy hosts.
#[command(visible_alias = "hosts")]
ListHosts,
/// Visa en proxy host (JSON) via id.
GetHost {
/// Proxy host-id.
id: i64,
},
/// Sök proxy hosts på domännamn (delsträng).
Find {
/// Söksträng, matchas mot domännamn.
query: String,
},
/// Skapa en proxy host (och valfritt ett Let's Encrypt-cert).
AddProxy(AddProxyArgs),
/// Aktivera en proxy host.
EnableHost {
id: i64,
},
/// Inaktivera en proxy host.
DisableHost {
id: i64,
},
/// Ta bort en proxy host.
DeleteHost {
id: i64,
},
/// Lista alla certifikat.
ListCerts,
/// Begär ett nytt Let's Encrypt-cert.
AddCert(AddCertArgs),
/// Förnya ett certifikat via id.
RenewCert {
id: i64,
},
/// Exportera alla proxy hosts (+ cert-meta) till JSON (git-vänlig backup).
Dump(DumpArgs),
/// Skapa/uppdatera proxy hosts utifrån en JSON-fil (deklarativt).
Apply(ApplyArgs),
/// Generisk HTTP-förfrågan mot vilket API som helst (curl-liknande).
Api(ApiArgs),
}
#[derive(Args, Debug)]
pub struct LoginArgs {
/// Användarnamn/e-post. Sparas (icke-hemligt) så du bara behöver ange det en gång.
#[arg(long)]
pub identity: Option<String>,
/// Läs lösenordet från stdin i stället för interaktiv prompt (för skript).
#[arg(long)]
pub password_stdin: bool,
/// Kräv ny inloggning om sessionen varit oanvänd längre än så här (sekunder). 0 = av.
#[arg(long)]
pub idle_timeout: Option<u64>,
}
#[derive(Args, Debug)]
pub struct AddProxyArgs {
/// Domännamn (kan anges flera gånger).
#[arg(long = "domain", required = true)]
pub domains: Vec<String>,
/// Container/host att vidarebefordra till (t.ex. vaultwarden).
#[arg(long)]
pub forward_host: String,
/// Intern port hos target.
#[arg(long)]
pub forward_port: u16,
/// Schema mot target.
#[arg(long, default_value = "http", value_parser = ["http", "https"])]
pub forward_scheme: String,
/// Tillåt websocket-uppgradering.
#[arg(long)]
pub websockets: bool,
/// Blockera vanliga exploits.
#[arg(long)]
pub block_exploits: bool,
/// Aktivera asset-caching.
#[arg(long)]
pub cache_assets: bool,
/// Begär ett nytt Let's Encrypt-cert och koppla det till hosten.
#[arg(long)]
pub ssl: bool,
/// Tvinga HTTPS (kräver --ssl eller --certificate-id).
#[arg(long)]
pub force_ssl: bool,
/// Aktivera HTTP/2.
#[arg(long)]
pub http2: bool,
/// Aktivera HSTS.
#[arg(long)]
pub hsts: bool,
/// E-post för Let's Encrypt (krävs med --ssl).
#[arg(long)]
pub letsencrypt_email: Option<String>,
/// Använd ett befintligt cert-id i stället för att begära nytt.
#[arg(long)]
pub certificate_id: Option<i64>,
/// Fri avancerad nginx-config.
#[arg(long)]
pub advanced_config: Option<String>,
}
#[derive(Args, Debug)]
pub struct AddCertArgs {
/// Domännamn för certet (kan anges flera gånger).
#[arg(long = "domain", required = true)]
pub domains: Vec<String>,
/// E-post för Let's Encrypt.
#[arg(long)]
pub letsencrypt_email: String,
}
#[derive(Args, Debug)]
pub struct DumpArgs {
/// Skriv till fil i stället för stdout.
#[arg(long, short)]
pub out: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct ApplyArgs {
/// JSON-fil att läsa (format från `dump`).
#[arg(long, short)]
pub file: PathBuf,
/// Visa vad som skulle göras utan att ändra något.
#[arg(long)]
pub dry_run: bool,
}
#[derive(Args, Debug)]
pub struct ApiArgs {
/// HTTP-metod.
#[arg(short = 'X', long, default_value = "GET")]
pub method: String,
/// URL, eller en sökväg som läggs på bas-URL:en (om den börjar med /).
pub url: String,
/// Header "Key: Value" (kan anges flera gånger).
#[arg(short = 'H', long = "header")]
pub headers: Vec<String>,
/// Request-body (rå sträng).
#[arg(short = 'd', long)]
pub data: Option<String>,
/// Läs request-body från fil.
#[arg(long)]
pub data_file: Option<PathBuf>,
/// Query-parameter "key=value" (kan anges flera gånger).
#[arg(short = 'q', long = "query")]
pub query: Vec<String>,
/// Sätt Authorization-header med denna nyckel (t.ex. en API-nyckel).
#[arg(long)]
pub auth_key: Option<String>,
/// Schema för --auth-key: "bearer" (Bearer <key>), "token" (token <key>) eller "raw".
#[arg(long, default_value = "bearer", value_parser = ["bearer", "token", "raw"])]
pub auth_scheme: String,
/// Använd den cachade NPM-sessionens token som Authorization: Bearer.
#[arg(long)]
pub use_session: bool,
/// Hoppa över TLS-certverifiering (som curl -k).
#[arg(short = 'k', long)]
pub insecure: bool,
/// Skriv bara ut svarskroppen (ingen statusrad).
#[arg(long)]
pub raw: bool,
}

183
src/client.rs Normal file
View File

@@ -0,0 +1,183 @@
//! HTTP-lager: en tunn wrapper runt reqwest (blocking) samt en
//! autentiserad NPM-klient som bär bas-URL + token.
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use reqwest::blocking::{Client, RequestBuilder, Response};
use reqwest::Method;
use serde_json::Value;
use std::time::Duration;
use crate::config::{parse_rfc3339, Config, Paths};
/// Bygg en reqwest blocking-klient.
pub fn build_client(insecure: bool) -> Result<Client> {
let mut b = Client::builder()
.user_agent(concat!("npmctl/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(60));
if insecure {
b = b.danger_accept_invalid_certs(true);
}
b.build().context("kunde inte bygga HTTP-klient")
}
/// Slå ihop bas-URL + path till en full URL.
pub fn join_url(base: &str, path: &str) -> String {
if path.starts_with("http://") || path.starts_with("https://") {
return path.to_string();
}
let base = base.trim_end_matches('/');
if path.starts_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
/// Autentiserad klient mot NPM:s API (`/api`).
pub struct NpmClient {
http: Client,
base_url: String,
token: String,
verbose: bool,
}
impl NpmClient {
/// Bygg en klient från en giltig session, med refresh + idle-kontroll.
/// Anropas av alla kommandon som kräver auth.
pub fn from_session(paths: &Paths, cfg: &Config, base_url: &str, verbose: bool) -> Result<Self> {
let mut session = paths.load_session().ok_or_else(|| {
anyhow!("ingen aktiv session — kör `npmctl login` först")
})?;
let now = Utc::now();
// Hård utgång från NPM-token.
if let Some(exp) = parse_rfc3339(&session.expires) {
if now >= exp {
paths.clear_session().ok();
bail!("sessionen har gått ut — kör `npmctl login` igen");
}
}
// Idle-timeout (sudo-likt): för länge sedan senaste anrop -> kräv ny login.
if let Some(idle) = cfg.idle_timeout.filter(|v| *v > 0) {
if let Some(last) = session.last_used.as_deref().and_then(parse_rfc3339) {
if (now - last).num_seconds() as u64 > idle {
paths.clear_session().ok();
bail!("sessionen inaktiv för länge ({idle}s) — kör `npmctl login` igen");
}
}
}
let http = build_client(false)?;
// Proaktiv refresh om mindre än en timme kvar.
if let Some(exp) = parse_rfc3339(&session.expires) {
if (exp - now).num_seconds() < 3600 {
if let Ok(new) = refresh_token(&http, base_url, &session.token, verbose) {
session.token = new.0;
session.expires = new.1;
}
}
}
session.last_used = Some(now.to_rfc3339());
session.base_url = base_url.to_string();
paths.save_session(&session).ok();
Ok(Self {
http,
base_url: base_url.to_string(),
token: session.token,
verbose,
})
}
fn api(&self, path: &str) -> String {
join_url(&self.base_url, &format!("/api{path}"))
}
fn send(&self, req: RequestBuilder) -> Result<(u16, Value)> {
let resp = req
.bearer_auth(&self.token)
.send()
.context("HTTP-anrop misslyckades")?;
read_json(resp, self.verbose)
}
pub fn get(&self, path: &str) -> Result<(u16, Value)> {
self.send(self.http.get(self.api(path)))
}
pub fn post(&self, path: &str, body: &Value) -> Result<(u16, Value)> {
self.send(self.http.post(self.api(path)).json(body))
}
pub fn put(&self, path: &str, body: &Value) -> Result<(u16, Value)> {
self.send(self.http.put(self.api(path)).json(body))
}
pub fn delete(&self, path: &str) -> Result<(u16, Value)> {
self.send(self.http.delete(self.api(path)))
}
}
/// Läs ett svar som (status, JSON). Om kroppen inte är JSON packas den som en sträng.
pub fn read_json(resp: Response, verbose: bool) -> Result<(u16, Value)> {
let status = resp.status().as_u16();
let text = resp.text().unwrap_or_default();
if verbose {
eprintln!("<- HTTP {status}");
}
let val = serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text));
Ok((status, val))
}
/// Byt användarnamn+lösen mot en token. Returnerar (token, expires).
pub fn login_request(
http: &Client,
base_url: &str,
identity: &str,
secret: &str,
) -> Result<(String, String)> {
let url = join_url(base_url, "/api/tokens");
let body = serde_json::json!({ "identity": identity, "secret": secret });
let resp = http.post(&url).json(&body).send().context("login-anrop misslyckades")?;
let (status, val) = read_json(resp, false)?;
if status != 200 {
bail!("inloggning nekades (HTTP {status}): {}", short(&val));
}
let token = val["token"].as_str().context("saknar token i svaret")?.to_string();
let expires = val["expires"].as_str().unwrap_or_default().to_string();
Ok((token, expires))
}
/// Förnya en token via GET /api/tokens.
pub fn refresh_token(
http: &Client,
base_url: &str,
token: &str,
verbose: bool,
) -> Result<(String, String)> {
let url = join_url(base_url, "/api/tokens");
let resp = http.get(&url).bearer_auth(token).send()?;
let (status, val) = read_json(resp, verbose)?;
if status != 200 {
bail!("kunde inte förnya token (HTTP {status})");
}
let new = val["token"].as_str().context("saknar token")?.to_string();
let expires = val["expires"].as_str().unwrap_or_default().to_string();
Ok((new, expires))
}
fn short(v: &Value) -> String {
let s = v.to_string();
if s.len() > 200 {
format!("{}", &s[..200])
} else {
s
}
}
/// Hjälpare för att bygga en godtycklig metod från sträng.
pub fn parse_method(s: &str) -> Result<Method> {
Method::from_bytes(s.to_uppercase().as_bytes()).map_err(|_| anyhow!("ogiltig HTTP-metod: {s}"))
}

129
src/config.rs Normal file
View File

@@ -0,0 +1,129 @@
//! Konfig och session (token-cache). Lösenord sparas ALDRIG — bara den
//! kortlivade token, precis som sudo cachar en capability, inte ditt lösen.
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:81";
/// Icke-hemlig konfig.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
/// Idle-timeout i sekunder (0/utelämnad = av).
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_timeout: Option<u64>,
}
/// Cachad session. Ligger i en 0600-fil.
#[derive(Debug, Serialize, Deserialize)]
pub struct Session {
pub token: String,
pub expires: String,
pub base_url: String,
/// RFC3339 för senaste användning (för idle-timeout).
#[serde(default)]
pub last_used: Option<String>,
}
/// Håller reda på var konfig/session bor.
pub struct Paths {
pub dir: PathBuf,
}
impl Paths {
pub fn new(override_dir: Option<PathBuf>) -> Result<Self> {
let dir = match override_dir {
Some(d) => d,
None => dirs::config_dir()
.context("kunde inte hitta konfigkatalog")?
.join("npmctl"),
};
Ok(Self { dir })
}
pub fn config_file(&self) -> PathBuf {
self.dir.join("config.json")
}
pub fn session_file(&self) -> PathBuf {
self.dir.join("session.json")
}
pub fn ensure_dir(&self) -> Result<()> {
fs::create_dir_all(&self.dir)
.with_context(|| format!("kunde inte skapa {}", self.dir.display()))?;
Ok(())
}
pub fn load_config(&self) -> Config {
fs::read_to_string(self.config_file())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_config(&self, cfg: &Config) -> Result<()> {
self.ensure_dir()?;
let s = serde_json::to_string_pretty(cfg)?;
fs::write(self.config_file(), s)?;
Ok(())
}
pub fn load_session(&self) -> Option<Session> {
fs::read_to_string(self.session_file())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
}
pub fn save_session(&self, s: &Session) -> Result<()> {
self.ensure_dir()?;
let path = self.session_file();
let json = serde_json::to_string_pretty(s)?;
fs::write(&path, json)?;
set_owner_only(&path)?;
Ok(())
}
pub fn clear_session(&self) -> Result<()> {
let path = self.session_file();
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}
}
/// Sätt filrättigheter till 0600 (endast ägaren).
#[cfg(unix)]
fn set_owner_only(path: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_owner_only(_path: &std::path::Path) -> Result<()> {
Ok(())
}
/// Räkna ut vilken bas-URL som gäller: flagga/env > konfig > default.
pub fn resolve_base_url(cli_base: &Option<String>, cfg: &Config) -> String {
cli_base
.clone()
.or_else(|| cfg.base_url.clone())
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}
pub fn parse_rfc3339(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}

89
src/generic.rs Normal file
View File

@@ -0,0 +1,89 @@
//! Generisk `api`-funktion: prata med vilket HTTP-API som helst.
//! Framtidssäkrar verktyget — fungerar även om vi byter från NPM till
//! t.ex. Caddy/Traefik, eller mot vilken tjänst som helst.
use anyhow::{bail, Context, Result};
use reqwest::header::{HeaderName, HeaderValue};
use crate::cli::ApiArgs;
use crate::client::{build_client, join_url, parse_method, read_json};
use crate::config::Paths;
pub fn run(paths: &Paths, base_url: &str, args: &ApiArgs, verbose: bool) -> Result<()> {
let http = build_client(args.insecure)?;
let method = parse_method(&args.method)?;
let url = join_url(base_url, &args.url);
let mut req = http.request(method, &url);
// Query-parametrar.
let mut pairs: Vec<(String, String)> = Vec::new();
for q in &args.query {
let (k, v) = q.split_once('=').context("--query måste vara key=value")?;
pairs.push((k.to_string(), v.to_string()));
}
if !pairs.is_empty() {
req = req.query(&pairs);
}
// Headers.
for h in &args.headers {
let (k, v) = h.split_once(':').context("--header måste vara \"Key: Value\"")?;
let name = HeaderName::from_bytes(k.trim().as_bytes()).context("ogiltigt headernamn")?;
let val = HeaderValue::from_str(v.trim()).context("ogiltigt headervärde")?;
req = req.header(name, val);
}
// Auth: antingen sessionens token, eller en explicit nyckel.
if args.use_session {
let s = paths
.load_session()
.context("--use-session men ingen aktiv session (kör `npmctl login`)")?;
req = req.bearer_auth(s.token);
} else if let Some(key) = &args.auth_key {
let header_val = match args.auth_scheme.as_str() {
"bearer" => format!("Bearer {key}"),
"token" => format!("token {key}"),
_ => key.clone(),
};
req = req.header("Authorization", HeaderValue::from_str(&header_val)?);
}
// Body.
let body = match (&args.data, &args.data_file) {
(Some(_), Some(_)) => bail!("ange antingen --data eller --data-file, inte båda"),
(Some(d), None) => Some(d.clone()),
(None, Some(f)) => Some(std::fs::read_to_string(f).with_context(|| format!("kunde inte läsa {}", f.display()))?),
(None, None) => None,
};
if let Some(b) = body {
// Sätt JSON-content-type om inget angetts och kroppen ser ut som JSON.
if !args.headers.iter().any(|h| h.to_lowercase().starts_with("content-type")) {
let trimmed = b.trim_start();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
req = req.header("Content-Type", "application/json");
}
}
req = req.body(b);
}
if verbose {
eprintln!("-> {} {}", args.method.to_uppercase(), url);
}
let resp = req.send().context("HTTP-anrop misslyckades")?;
let (status, val) = read_json(resp, verbose)?;
let pretty = serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
if args.raw {
println!("{pretty}");
} else {
println!("HTTP {status}");
println!("{pretty}");
}
if !(200..300).contains(&status) {
std::process::exit(1);
}
Ok(())
}

76
src/main.rs Normal file
View File

@@ -0,0 +1,76 @@
//! npmctl — CLI för Nginx Proxy Manager med en generisk API-klient.
mod cli;
mod client;
mod config;
mod generic;
mod npm;
use clap::error::ErrorKind;
use clap::{CommandFactory, Parser};
use cli::{Cli, Command};
use client::NpmClient;
use config::{resolve_base_url, Paths};
fn main() {
// Egen parse-hantering: vid felaktig input skriver vi ut hjälpen
// (utöver felmeddelandet), enligt önskemål. --help/--version skrivs ut normalt.
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
print!("{e}");
std::process::exit(0);
}
_ => {
eprintln!("{e}\n");
Cli::command().print_long_help().ok();
std::process::exit(2);
}
},
};
if let Err(err) = run(cli) {
eprintln!("fel: {err:#}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> anyhow::Result<()> {
let paths = Paths::new(cli.config_dir.clone())?;
let cfg = paths.load_config();
let base_url = resolve_base_url(&cli.base_url, &cfg);
let verbose = cli.verbose;
// Hjälpare: bygg en autentiserad NPM-klient.
let auth = |v: bool| NpmClient::from_session(&paths, &cfg, &base_url, v);
match &cli.command {
// --- session ---
Command::Login(args) => npm::login(&paths, cfg, &base_url, args),
Command::Logout => npm::logout(&paths),
Command::Status => npm::status(&paths, &cfg, &base_url),
// --- proxy hosts ---
Command::ListHosts => npm::list_hosts(&auth(verbose)?),
Command::GetHost { id } => npm::get_host(&auth(verbose)?, *id),
Command::Find { query } => npm::find(&auth(verbose)?, query),
Command::AddProxy(args) => npm::add_proxy(&auth(verbose)?, args),
Command::EnableHost { id } => npm::set_host_enabled(&auth(verbose)?, *id, true),
Command::DisableHost { id } => npm::set_host_enabled(&auth(verbose)?, *id, false),
Command::DeleteHost { id } => npm::delete_host(&auth(verbose)?, *id),
// --- certifikat ---
Command::ListCerts => npm::list_certs(&auth(verbose)?),
Command::AddCert(args) => npm::add_cert(&auth(verbose)?, args),
Command::RenewCert { id } => npm::renew_cert(&auth(verbose)?, *id),
// --- backup/deklarativt ---
Command::Dump(args) => npm::dump(&auth(verbose)?, args),
Command::Apply(args) => npm::apply(&auth(verbose)?, args),
// --- generisk ---
Command::Api(args) => generic::run(&paths, &base_url, args, verbose),
}
}

378
src/npm.rs Normal file
View File

@@ -0,0 +1,378 @@
//! Implementering av NPM-kommandona.
use anyhow::{bail, Context, Result};
use chrono::Utc;
use serde_json::{json, Value};
use std::io::{self, Write};
use crate::cli::{AddCertArgs, AddProxyArgs, ApplyArgs, DumpArgs, LoginArgs};
use crate::client::{build_client, login_request, NpmClient};
use crate::config::{parse_rfc3339, Config, Paths, Session};
/// `login` — fråga efter lösenord, byt mot token, cacha sessionen.
pub fn login(paths: &Paths, mut cfg: Config, base_url: &str, args: &LoginArgs) -> Result<()> {
// Identitet: flagga > konfig > prompt.
let identity = match &args.identity {
Some(i) => i.clone(),
None => match &cfg.identity {
Some(i) => i.clone(),
None => prompt_line("Användarnamn/e-post: ")?,
},
};
let secret = if args.password_stdin {
let mut s = String::new();
io::stdin().read_line(&mut s)?;
s.trim_end_matches(['\n', '\r']).to_string()
} else {
// Läses utan eko och skickas aldrig via argv — når aldrig loggar.
rpassword::prompt_password(format!("Lösenord för {identity}: "))?
};
let http = build_client(false)?;
let (token, expires) = login_request(&http, base_url, &identity, &secret)?;
let session = Session {
token,
expires: expires.clone(),
base_url: base_url.to_string(),
last_used: Some(Utc::now().to_rfc3339()),
};
paths.save_session(&session)?;
// Spara icke-hemlig konfig så nästa login bara behöver lösenordet.
cfg.identity = Some(identity);
cfg.base_url.get_or_insert_with(|| base_url.to_string());
if let Some(t) = args.idle_timeout {
cfg.idle_timeout = Some(t);
}
paths.save_config(&cfg)?;
println!("Inloggad. Token giltig till {}.", if expires.is_empty() { "?".into() } else { expires });
Ok(())
}
/// `logout` — radera sessionen.
pub fn logout(paths: &Paths) -> Result<()> {
paths.clear_session()?;
println!("Utloggad (session raderad).");
Ok(())
}
/// `status` — visa inloggningsläge.
pub fn status(paths: &Paths, cfg: &Config, base_url: &str) -> Result<()> {
println!("Bas-URL: {base_url}");
if let Some(id) = &cfg.identity {
println!("Identitet: {id}");
}
match paths.load_session() {
None => println!("Status: inte inloggad (kör `npmctl login`)"),
Some(s) => {
print!("Status: inloggad");
if let Some(exp) = parse_rfc3339(&s.expires) {
let left = (exp - Utc::now()).num_minutes();
if left > 0 {
println!(" — token går ut om {left} min ({})", s.expires);
} else {
println!(" — token UTGÅNGEN ({})", s.expires);
}
} else {
println!();
}
if let Some(idle) = cfg.idle_timeout.filter(|v| *v > 0) {
println!("Idle-timeout: {idle}s");
}
}
}
Ok(())
}
/// `list-hosts`
pub fn list_hosts(c: &NpmClient) -> Result<()> {
let (st, v) = c.get("/nginx/proxy-hosts")?;
ensure_ok(st, &v)?;
print_hosts_table(v.as_array().map(|a| a.as_slice()).unwrap_or(&[]));
Ok(())
}
/// `get-host <id>`
pub fn get_host(c: &NpmClient, id: i64) -> Result<()> {
let (st, v) = c.get(&format!("/nginx/proxy-hosts/{id}"))?;
ensure_ok(st, &v)?;
println!("{}", serde_json::to_string_pretty(&v)?);
Ok(())
}
/// `find <query>`
pub fn find(c: &NpmClient, query: &str) -> Result<()> {
let (st, v) = c.get("/nginx/proxy-hosts")?;
ensure_ok(st, &v)?;
let q = query.to_lowercase();
let matches: Vec<Value> = v
.as_array()
.map(|a| {
a.iter()
.filter(|h| {
h["domain_names"]
.as_array()
.map(|ds| ds.iter().any(|d| d.as_str().unwrap_or("").to_lowercase().contains(&q)))
.unwrap_or(false)
})
.cloned()
.collect()
})
.unwrap_or_default();
if matches.is_empty() {
println!("Inga proxy hosts matchar \"{query}\".");
} else {
print_hosts_table(&matches);
}
Ok(())
}
/// `add-proxy`
pub fn add_proxy(c: &NpmClient, args: &AddProxyArgs) -> Result<()> {
// Steg 1: ev. begär certifikat.
let mut cert_id = args.certificate_id.unwrap_or(0);
if args.ssl && args.certificate_id.is_none() {
let email = args
.letsencrypt_email
.as_deref()
.context("--ssl kräver --letsencrypt-email")?;
println!("Begär Let's Encrypt-cert för {:?}", args.domains);
cert_id = request_cert(c, &args.domains, email)?;
println!("Cert skapat (id {cert_id}).");
}
let force_ssl = args.force_ssl || args.ssl;
if force_ssl && cert_id == 0 {
bail!("--force-ssl kräver ett cert (använd --ssl eller --certificate-id)");
}
let body = json!({
"domain_names": args.domains,
"forward_scheme": args.forward_scheme,
"forward_host": args.forward_host,
"forward_port": args.forward_port,
"access_list_id": 0,
"certificate_id": cert_id,
"ssl_forced": force_ssl,
"http2_support": args.http2,
"hsts_enabled": args.hsts,
"hsts_subdomains": false,
"caching_enabled": args.cache_assets,
"block_exploits": args.block_exploits,
"advanced_config": args.advanced_config.clone().unwrap_or_default(),
"allow_websocket_upgrade": args.websockets,
"meta": { "letsencrypt_agree": true, "dns_challenge": false },
"locations": []
});
let (st, v) = c.post("/nginx/proxy-hosts", &body)?;
ensure_ok(st, &v)?;
println!(
"Proxy host skapad (id {}) för {:?} -> {}:{}",
v["id"], args.domains, args.forward_host, args.forward_port
);
Ok(())
}
fn request_cert(c: &NpmClient, domains: &[String], email: &str) -> Result<i64> {
let body = json!({
"provider": "letsencrypt",
"domain_names": domains,
"meta": { "letsencrypt_email": email, "letsencrypt_agree": true, "dns_challenge": false }
});
let (st, v) = c.post("/nginx/certificates", &body)?;
ensure_ok(st, &v)?;
v["id"].as_i64().context("saknar cert-id i svaret")
}
/// `enable-host` / `disable-host`
pub fn set_host_enabled(c: &NpmClient, id: i64, enabled: bool) -> Result<()> {
let verb = if enabled { "enable" } else { "disable" };
let (st, v) = c.post(&format!("/nginx/proxy-hosts/{id}/{verb}"), &json!({}))?;
ensure_ok(st, &v)?;
println!("Host {id} {}.", if enabled { "aktiverad" } else { "inaktiverad" });
Ok(())
}
/// `delete-host`
pub fn delete_host(c: &NpmClient, id: i64) -> Result<()> {
let (st, v) = c.delete(&format!("/nginx/proxy-hosts/{id}"))?;
ensure_ok(st, &v)?;
println!("Host {id} borttagen.");
Ok(())
}
/// `list-certs`
pub fn list_certs(c: &NpmClient) -> Result<()> {
let (st, v) = c.get("/nginx/certificates")?;
ensure_ok(st, &v)?;
let empty = vec![];
let arr = v.as_array().unwrap_or(&empty);
println!("{:<5} {:<12} {:<35} {}", "ID", "PROVIDER", "NAMN", "GÅR UT");
for cert in arr {
println!(
"{:<5} {:<12} {:<35} {}",
cert["id"],
cert["provider"].as_str().unwrap_or("-"),
cert["nice_name"].as_str().unwrap_or("-"),
cert["expires_on"].as_str().unwrap_or("-")
);
}
Ok(())
}
/// `add-cert`
pub fn add_cert(c: &NpmClient, args: &AddCertArgs) -> Result<()> {
let id = request_cert(c, &args.domains, &args.letsencrypt_email)?;
println!("Cert skapat (id {id}) för {:?}.", args.domains);
Ok(())
}
/// `renew-cert`
pub fn renew_cert(c: &NpmClient, id: i64) -> Result<()> {
let (st, v) = c.post(&format!("/nginx/certificates/{id}/renew"), &json!({}))?;
ensure_ok(st, &v)?;
println!("Cert {id} förnyat.");
Ok(())
}
/// `dump` — exportera hosts + cert-meta till JSON.
pub fn dump(c: &NpmClient, args: &DumpArgs) -> Result<()> {
let (sh, hosts) = c.get("/nginx/proxy-hosts")?;
ensure_ok(sh, &hosts)?;
let (sc, certs) = c.get("/nginx/certificates")?;
ensure_ok(sc, &certs)?;
let out = json!({
"version": 1,
"exported_at": Utc::now().to_rfc3339(),
"proxy_hosts": hosts,
"certificates": certs,
});
let text = serde_json::to_string_pretty(&out)?;
match &args.out {
Some(path) => {
std::fs::write(path, &text)?;
println!("Skrev {} proxy hosts till {}", hosts.as_array().map(|a| a.len()).unwrap_or(0), path.display());
}
None => println!("{text}"),
}
Ok(())
}
/// `apply` — skapa/uppdatera proxy hosts utifrån en dump-fil.
pub fn apply(c: &NpmClient, args: &ApplyArgs) -> Result<()> {
let text = std::fs::read_to_string(&args.file)
.with_context(|| format!("kunde inte läsa {}", args.file.display()))?;
let doc: Value = serde_json::from_str(&text).context("ogiltig JSON")?;
let desired = doc["proxy_hosts"].as_array().context("saknar proxy_hosts i filen")?;
// Hämta nuvarande hosts för att matcha på domän.
let (st, current) = c.get("/nginx/proxy-hosts")?;
ensure_ok(st, &current)?;
let current = current.as_array().cloned().unwrap_or_default();
for host in desired {
let domains: Vec<String> = host["domain_names"]
.as_array()
.map(|a| a.iter().filter_map(|d| d.as_str().map(String::from)).collect())
.unwrap_or_default();
if domains.is_empty() {
continue;
}
let existing_id = current.iter().find_map(|h| {
let hd: Vec<&str> = h["domain_names"].as_array().map(|a| a.iter().filter_map(|d| d.as_str()).collect()).unwrap_or_default();
if hd.iter().any(|d| domains.iter().any(|dd| dd == d)) {
h["id"].as_i64()
} else {
None
}
});
let body = sanitize_host(host);
match existing_id {
Some(id) => {
if args.dry_run {
println!("[dry-run] skulle UPPDATERA host {id} {domains:?}");
} else {
let (s, v) = c.put(&format!("/nginx/proxy-hosts/{id}"), &body)?;
ensure_ok(s, &v)?;
println!("Uppdaterade host {id} {domains:?}");
}
}
None => {
if args.dry_run {
println!("[dry-run] skulle SKAPA host {domains:?}");
} else {
let (s, v) = c.post("/nginx/proxy-hosts", &body)?;
ensure_ok(s, &v)?;
println!("Skapade host {} {domains:?}", v["id"]);
}
}
}
}
Ok(())
}
/// Plocka ut bara de fält NPM accepterar vid create/update.
fn sanitize_host(h: &Value) -> Value {
json!({
"domain_names": h["domain_names"],
"forward_scheme": h["forward_scheme"],
"forward_host": h["forward_host"],
"forward_port": h["forward_port"],
"access_list_id": h.get("access_list_id").cloned().unwrap_or(json!(0)),
"certificate_id": h.get("certificate_id").cloned().unwrap_or(json!(0)),
"ssl_forced": h.get("ssl_forced").cloned().unwrap_or(json!(false)),
"http2_support": h.get("http2_support").cloned().unwrap_or(json!(false)),
"hsts_enabled": h.get("hsts_enabled").cloned().unwrap_or(json!(false)),
"hsts_subdomains": h.get("hsts_subdomains").cloned().unwrap_or(json!(false)),
"caching_enabled": h.get("caching_enabled").cloned().unwrap_or(json!(false)),
"block_exploits": h.get("block_exploits").cloned().unwrap_or(json!(false)),
"advanced_config": h.get("advanced_config").cloned().unwrap_or(json!("")),
"allow_websocket_upgrade": h.get("allow_websocket_upgrade").cloned().unwrap_or(json!(false)),
"meta": h.get("meta").cloned().unwrap_or(json!({"letsencrypt_agree": true, "dns_challenge": false})),
"locations": h.get("locations").cloned().unwrap_or(json!([])),
})
}
// ---- hjälpare ----
fn ensure_ok(status: u16, body: &Value) -> Result<()> {
if (200..300).contains(&status) {
Ok(())
} else {
bail!("NPM svarade HTTP {status}: {}", body);
}
}
fn print_hosts_table(hosts: &[Value]) {
println!("{:<5} {:<4} {:<40} {}", "ID", "ON", "DOMÄNER", "-> TARGET");
for h in hosts {
let id = h["id"].as_i64().unwrap_or(0);
let enabled = if h["enabled"].as_i64().unwrap_or(0) == 1 || h["enabled"].as_bool().unwrap_or(false) { "" } else { "" };
let domains = h["domain_names"]
.as_array()
.map(|a| a.iter().filter_map(|d| d.as_str()).collect::<Vec<_>>().join(","))
.unwrap_or_default();
let target = format!(
"{}://{}:{}",
h["forward_scheme"].as_str().unwrap_or("http"),
h["forward_host"].as_str().unwrap_or("-"),
h["forward_port"]
);
println!("{id:<5} {enabled:<4} {domains:<40} -> {target}");
}
io::stdout().flush().ok();
}
fn prompt_line(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut s = String::new();
io::stdin().read_line(&mut s)?;
Ok(s.trim().to_string())
}