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

View File

@@ -0,0 +1,87 @@
name: release
# Bygger fristående npmctl-binärer för linux-arm64 (native på Pi-runnern) och
# linux-x64 (cross via gcc-x86-64-linux-gnu) och publicerar dem som assets på
# en Gitea-release. Runnern använder sin egen GITHUB_TOKEN mot Gitea-API:t —
# ingen token behöver hanteras manuellt.
on:
push:
branches: [main, master] # varje main-push -> rullande 'latest'-release
tags: ["v*"] # vX.Y.Z -> versionerad release
workflow_dispatch:
inputs:
tag:
description: "Release-tag (lämna tom för 'latest')"
required: false
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Bestäm tag
id: t
run: |
case "$GITHUB_REF" in
refs/tags/*) T="$GITHUB_REF_NAME" ;;
*) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;;
esac
echo "tag=$T" >> "$GITHUB_OUTPUT"
echo "bygger release: $T"
- name: Installera toolchain + bygg binärer
run: |
set -eux
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO=sudo
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends gcc gcc-x86-64-linux-gnu jq curl ca-certificates
# Rust (om det saknas på runnern).
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
fi
. "$HOME/.cargo/env"
rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu
mkdir -p dist
# arm64 — native på Pi-runnern.
cargo build --release --target aarch64-unknown-linux-gnu
cp target/aarch64-unknown-linux-gnu/release/npmctl dist/npmctl-linux-arm64
# x64 — cross. Peka ut cross-linker och C-compiler (för ring).
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc
export AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar
cargo build --release --target x86_64-unknown-linux-gnu
cp target/x86_64-unknown-linux-gnu/release/npmctl dist/npmctl-linux-x64
cd dist
sha256sum npmctl-linux-* > checksums.txt
ls -la
- name: Skapa Gitea-release + ladda upp assets
env:
API: http://gitea-d:3000/api/v1
REPO: ${{ github.repository }}
TAG: ${{ steps.t.outputs.tag }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
B=dist
BODY="npmctl $TAG — CLI för Nginx Proxy Manager (linux x64/arm64). Se wiki: https://gitea.brasse-pc.eu/brasse/Npm-cli/wiki"
rid=$(curl -s -X POST "$API/repos/$REPO/releases" -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
[ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" -H "Authorization: token $TOKEN" | jq -r '.id')
echo "release id: $rid"
for f in npmctl-linux-x64 npmctl-linux-arm64 checksums.txt; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" -H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
echo "ersätter befintlig $f (id $aid)"
curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" -H "Authorization: token $TOKEN" -o /dev/null
fi
echo "laddar upp $f ..."
curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" -H "Authorization: token $TOKEN" -F "attachment=@$B/$f" -o /dev/null -w " -> HTTP %{http_code}\n"
done

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Rust build output
/target
# Lokal build-mapp (VS Code-tasken lägger binären här) — ska inte in i repot
/build
# Diverse
*.log
.DS_Store

31
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,31 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build (release -> ./build)",
"detail": "Bygger npmctl i release-läge och lägger binären i ./build/npmctl",
"type": "shell",
"options": {
"shell": { "executable": "/bin/bash", "args": ["-c"] },
"env": {
"//": "Bygg utanför NTFS-disken (cargo skapar många småfiler); kopiera sedan bara binären in i repot.",
"CARGO_TARGET_DIR": "${env:HOME}/.cache/npmctl-target"
}
},
"command": "cargo build --release && mkdir -p build && cp \"$CARGO_TARGET_DIR/release/npmctl\" build/npmctl && echo '--> build/npmctl' && ls -la build/",
"problemMatcher": ["$rustc"],
"group": { "kind": "build", "isDefault": true }
},
{
"label": "run -- status",
"detail": "Bygger och kör `npmctl status`",
"type": "shell",
"options": {
"shell": { "executable": "/bin/bash", "args": ["-c"] },
"env": { "CARGO_TARGET_DIR": "${env:HOME}/.cache/npmctl-target" }
},
"command": "cargo run --release -- status",
"problemMatcher": ["$rustc"]
}
]
}

1786
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "npmctl"
version = "0.1.0"
edition = "2021"
description = "CLI för Nginx Proxy Manager (NPM) med en generisk API-klient för framtidssäkring"
authors = ["brasse"]
license = "MIT"
repository = "https://gitea.brasse-pc.eu/brasse/Npm-cli"
[[bin]]
name = "npmctl"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rpassword = "7"
dirs = "5"
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 brasse
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

66
README.md Normal file
View File

@@ -0,0 +1,66 @@
# npmctl
En liten CLI i Rust för att styra **Nginx Proxy Manager (NPM)** via dess API,
plus en generisk `api`-funktion för att prata med vilket HTTP-API som helst
(framtidssäkring om proxyn byts ut mot t.ex. Caddy/Traefik).
Autentisering fungerar som `sudo`: kör `npmctl login` **en gång** och skriv
lösenordet i prompten (det når aldrig argv/loggar). En kortlivad token cachas
lokalt (`~/.config/npmctl/session.json`, `0600`) och används tills den går ut —
lösenordet sparas aldrig.
👉 **Användning och alla kommandon:** se [wiki:n](https://gitea.brasse-pc.eu/brasse/Npm-cli/wiki).
## Installera (färdig binär)
Hämta senaste binär från [releases](https://gitea.brasse-pc.eu/brasse/Npm-cli/releases):
```sh
# välj arkitektur: x64 eller arm64
curl -fsSL -o npmctl https://gitea.brasse-pc.eu/brasse/Npm-cli/releases/download/latest/npmctl-linux-x64
chmod +x npmctl && sudo mv npmctl /usr/local/bin/
npmctl --help
```
## Sätt upp projektet (utveckling)
Krav: [Rust](https://rustup.rs) (stable).
```sh
git clone https://gitea.brasse-pc.eu/brasse/Npm-cli.git
cd Npm-cli
cargo build --release # binär i target/release/npmctl
cargo run -- --help
```
### Bygga till ./build
I VS Code: **Terminal → Run Build Task** (`Ctrl+Shift+B`) kör tasken
*build (release -> ./build)*, som bygger utanför repo-disken och lägger binären
i `build/npmctl`. Mappen `build/` (och `target/`) är gitignoread.
Motsvarande manuellt:
```sh
CARGO_TARGET_DIR="$HOME/.cache/npmctl-target" cargo build --release
mkdir -p build && cp "$HOME/.cache/npmctl-target/release/npmctl" build/npmctl
```
## Releaser
Push till `main` (eller en `v*`-tag) triggar Gitea Actions-workflowen
`.gitea/workflows/release.yaml`, som bygger binärer för **linux-x64** och
**linux-arm64** och laddar upp dem till en release. Runnern använder sin egen
`GITHUB_TOKEN` — ingen token hanteras manuellt.
## Konfiguration
| Var | Beskrivning |
|-----|-------------|
| `--base-url` / `NPMCTL_BASE_URL` | NPM:s API-bas, t.ex. `http://127.0.0.1:81`. Standard: `http://127.0.0.1:81`. |
| `~/.config/npmctl/config.json` | Icke-hemligt: `base_url`, `identity`, `idle_timeout`. |
| `~/.config/npmctl/session.json` | Cachad token (`0600`). Skapas av `login`, raderas av `logout`. |
## Licens
MIT.

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())
}