commit 000ba273873b1c40105b903d5e9d03429ad9198f Author: D Stephenson Date: Tue May 5 19:57:47 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1822871 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +*.pyc +config.py +*.bak +configs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1a4e94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . +COPY config.py . +COPY templates/ templates/ + +EXPOSE 8003 + +CMD ["python", "app.py"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..87de881 --- /dev/null +++ b/app.py @@ -0,0 +1,147 @@ +from flask import Flask, jsonify, request, render_template, send_from_directory +import requests +import os +import json +import time +import socket +import paramiko + +from config import NOCODB_URL, NOCODB_TOKEN, NOCODB_BASE_ID, NOCODB_TABLE_ID, CONFIGS_DIR + +app = Flask(__name__) +app.config['TEMPLATES_AUTO_RELOAD'] = True + +os.makedirs(CONFIGS_DIR, exist_ok=True) + +# ─── NocoDB ─────────────────────────────────────────────────────────────────── + +def get_switches(dept=None): + headers = {"xc-token": NOCODB_TOKEN} + params = { + "where": "(Active,eq,YES)", + "limit": 500, + "sort": "Hostname" + } + if dept: + params["where"] = f"(Active,eq,YES)~and(Dept,eq,{dept})" + + url = f"{NOCODB_URL}/api/v1/db/data/noco/{NOCODB_BASE_ID}/{NOCODB_TABLE_ID}" + resp = requests.get(url, headers=headers, params=params, timeout=10) + resp.raise_for_status() + return resp.json().get("list", []) + + +# ─── Routes ─────────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/switches") +def api_switches(): + try: + switches = get_switches() + return jsonify({"ok": True, "switches": switches}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/save", methods=["POST"]) +def api_save(): + data = request.get_json() + hostname = data.get("hostname", "").strip() + content = data.get("content", "") + + if not hostname: + return jsonify({"ok": False, "error": "No hostname provided"}), 400 + + filename = f"{hostname}.conf" + filepath = os.path.join(CONFIGS_DIR, filename) + + # Check if file already exists + exists = os.path.isfile(filepath) + if exists and not data.get("overwrite", False): + return jsonify({"ok": False, "exists": True, "filename": filename}), 409 + + try: + with open(filepath, "w") as f: + f.write(content) + return jsonify({"ok": True, "filename": filename, "path": filepath}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/configs") +def api_configs(): + try: + files = sorted([ + f for f in os.listdir(CONFIGS_DIR) if f.endswith(".conf") + ]) + return jsonify({"ok": True, "files": files}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.route("/api/web-access", methods=["POST"]) +def api_web_access(): + data = request.get_json() + ip = data.get("ip", "").strip() + username = data.get("username", "").strip() + password = data.get("password", "").strip() + action = data.get("action", "") # "enable" or "disable" + + if not ip or not username or not password: + return jsonify({"ok": False, "error": "Missing IP or credentials"}), 400 + if action not in ("enable", "disable"): + return jsonify({"ok": False, "error": "Invalid action"}), 400 + + print(f"[web-access] {action} {ip} user={username!r} pass_len={len(password)}", flush=True) + + cmd = "ip http secure-server" if action == "enable" else "no ip http secure-server" + + try: + sock = socket.create_connection((ip, 22), timeout=10) + transport = paramiko.Transport(sock) + transport.start_client(timeout=10) + + # Try password auth. + # BadAuthenticationType means the server disallows password auth entirely + # (some FS switches require keyboard-interactive instead). + # A plain AuthenticationException means the credentials were rejected — don't + # fall back to keyboard-interactive in that case. + try: + transport.auth_password(username, password) + except paramiko.ssh_exception.BadAuthenticationType as e: + if "keyboard-interactive" in e.allowed_types: + transport.auth_interactive_dumb(username, [password]) + else: + raise paramiko.AuthenticationException( + f"Password auth not accepted; server allows: {e.allowed_types}" + ) + + shell = transport.open_session() + shell.get_pty() + shell.invoke_shell() + time.sleep(1) + shell.recv(65535) # drain login banner + + for command in ["conf t", cmd, "end"]: + shell.send(command + "\n") + time.sleep(0.5) + + # copy run start may prompt for filename — send \n to confirm default + shell.send("copy run start\n") + time.sleep(0.3) + shell.send("\n") + time.sleep(1.5) + + output = shell.recv(65535).decode("utf-8", errors="ignore") if shell.recv_ready() else "" + transport.close() + return jsonify({"ok": True, "output": output}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8003, debug=False) diff --git a/config.example.py b/config.example.py new file mode 100644 index 0000000..3098ae9 --- /dev/null +++ b/config.example.py @@ -0,0 +1,11 @@ +# config.py - Switch Config Manager +# Copy this file to config.py and fill in your values. + +# ─── NocoDB ─────────────────────────────────────────────────────────────────── +NOCODB_URL = "http://your-nocodb-host:8080" +NOCODB_TOKEN = "your-nocodb-api-token" +NOCODB_BASE_ID = "your-base-id" +NOCODB_TABLE_ID = "your-table-id" + +# ─── Output ─────────────────────────────────────────────────────────────────── +CONFIGS_DIR = "/configs" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e2daa4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ + +services: + switch-config-manager: + build: . + container_name: switch-config-manager + restart: unless-stopped + ports: + - "8003:8003" + volumes: + - ./config.py:/app/config.py + - ./templates/index.html:/app/templates/index.html + - ./configs:/configs + environment: + - PYTHONUNBUFFERED=1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e671e22 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +requests==2.31.0 +paramiko==3.4.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e21828c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1409 @@ + + + + + +Switch Config Manager + + + + + +
+
+ +
+
+ CONNECTING... +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+
HOSTNAME
+
IP
+
LOCATION
+
ASSET TAG
+
DEPT
+
MAC
+
USERNAME
+
PASSWORD
+
+
+ + + + + +
+
+ + +
+
+ +
+ +
+
+ MGMT VLAN # + +
+
+ MGMT NAME + +
+
+ ELEC DATA # + +
+
+ ELEC NAME + +
+
+ GW DATA # + +
+
+ GW NAME + +
+
+ SEC DATA # + +
+
+ SEC NAME + +
+
+ BLACKHOLE # + +
+
+
+ +
+ +
+
+ MGMT GATEWAY + +
+
+
+ +
+ +
+
+ DEFAULT USERNAME + +
+
+ DEFAULT PASSWORD (plaintext) + +
+
+ +
+
+
+ +
+ +
+
+ ENGINE ID + +
+
+ RADIUS KEY (plaintext) + +
+
+ SNMP AUTH (checkmk) + +
+
+ SNMP PRIV (checkmk) + +
+
+ SNMP AUTH (jdisc) + +
+
+ SNMP PRIV (jdisc) + +
+
+
+ +
+
+ + +
+ +
+ + + +
TEMPLATE N/A — NON-FS SWITCH
+ + +
+ +
+
+
No configs saved yet
+
+ +
+ + +
+ +
+
+ CONFIG PREVIEW // + none selected +
+
+
+
+
SELECT A SWITCH TO GENERATE CONFIG
+
+
+
+ + + +
+ +
+ + + + + +
+ + + +