Initial npmctl: NPM CLI + generic API client
Some checks failed
release / build-release (push) Failing after 4m23s
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:
87
.gitea/workflows/release.yaml
Normal file
87
.gitea/workflows/release.yaml
Normal 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
9
.gitignore
vendored
Normal 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
31
.vscode/tasks.json
vendored
Normal 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
1786
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal 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
21
LICENSE
Normal 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
66
README.md
Normal 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
215
src/cli.rs
Normal 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
183
src/client.rs
Normal 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
129
src/config.rs
Normal 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
89
src/generic.rs
Normal 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
76
src/main.rs
Normal 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
378
src/npm.rs
Normal 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, ¤t)?;
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user