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