Add web service inspection report
This commit is contained in:
1 parent
ace93c721f
commit
25162b8a45
3 files changed
+676
No files matched your search
Executable
+604
@@ -0,0 +1,604 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# inspect-web-services.sh — inspect web servers, reverse proxies, and port 80/443 listeners.
|
||||
# Full documentation: see inspect-web-services.md in this same folder.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "${SCRIPT_DIR}/../lib/common.sh"
|
||||
|
||||
SCRIPT_HELP=$(cat <<'HELP'
|
||||
Usage: ./Web/inspect-web-services.sh [OPTIONS]
|
||||
|
||||
Detects installed web servers and reverse proxies, checks TCP/UDP listeners on
|
||||
ports 80 and 443, and reports Apache/Nginx virtual hosts, URLs, and enabled-site
|
||||
configuration links. It does not change service or firewall settings.
|
||||
|
||||
Options:
|
||||
-o, --output PATH Save directly to PATH; otherwise ask interactively
|
||||
-f, --format FORMAT Report format: txt, json, or html
|
||||
--force Overwrite an existing output file without prompting
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Examples:
|
||||
./Web/inspect-web-services.sh
|
||||
./Web/inspect-web-services.sh --output "$HOME/web-services.json" --format json
|
||||
sudo ./Web/inspect-web-services.sh
|
||||
HELP
|
||||
)
|
||||
|
||||
common::show_help_if_requested "$@"
|
||||
|
||||
OUTPUT_PATH=""
|
||||
OUTPUT_FORMAT=""
|
||||
FORCE=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o|--output)
|
||||
[[ $# -ge 2 && -n "$2" ]] || common::die "$1 requires a path"
|
||||
OUTPUT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-f|--format)
|
||||
[[ $# -ge 2 ]] || common::die "$1 requires txt, json, or html"
|
||||
OUTPUT_FORMAT="${2,,}"
|
||||
case "$OUTPUT_FORMAT" in
|
||||
txt|json|html) ;;
|
||||
*) common::die "unsupported report format: $2 (use txt, json, or html)" ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
common::die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
common::require_cmd python3
|
||||
common::require_cmd cat
|
||||
common::require_cmd ss
|
||||
|
||||
python3 -c "$(cat <<'PYCODE'
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
SERVER_DEFINITIONS = [
|
||||
{"name": "Apache HTTP Server", "binaries": ["apache2ctl", "apachectl", "httpd", "apache2"], "packages": [r"^apache2(?:-|$)", r"^httpd(?:-|$)"], "units": ["apache2.service", "httpd.service"], "ports": [80, 443]},
|
||||
{"name": "Nginx", "binaries": ["nginx"], "packages": [r"^nginx(?:-|$)"], "units": ["nginx.service"], "ports": [80, 443]},
|
||||
{"name": "Caddy", "binaries": ["caddy"], "packages": [r"^caddy(?:-|$)"], "units": ["caddy.service"], "ports": [80, 443]},
|
||||
{"name": "HAProxy", "binaries": ["haproxy"], "packages": [r"^haproxy(?:-|$)"], "units": ["haproxy.service"], "ports": [80, 443]},
|
||||
{"name": "Traefik", "binaries": ["traefik"], "packages": [r"^traefik(?:-|$)"], "units": ["traefik.service"], "ports": [80, 443]},
|
||||
{"name": "Lighttpd", "binaries": ["lighttpd"], "packages": [r"^lighttpd(?:-|$)"], "units": ["lighttpd.service"], "ports": [80, 443]},
|
||||
{"name": "Envoy", "binaries": ["envoy"], "packages": [r"^envoy(?:-|$)"], "units": ["envoy.service"], "ports": [80, 443]},
|
||||
{"name": "Varnish", "binaries": ["varnishd"], "packages": [r"^varnish(?:-|$)"], "units": ["varnish.service"], "ports": [80, 443]},
|
||||
{"name": "OpenResty", "binaries": ["openresty"], "packages": [r"^openresty(?:-|$)"], "units": ["openresty.service"], "ports": [80, 443]},
|
||||
]
|
||||
SEARCH_PATHS = ("/usr/sbin", "/usr/bin", "/usr/local/sbin", "/usr/local/bin", "/sbin", "/bin")
|
||||
|
||||
|
||||
def run(command, timeout=15):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return None, str(error)
|
||||
if result.returncode != 0:
|
||||
return result.stdout.strip() or None, result.stderr.strip() or f"exit status {result.returncode}"
|
||||
return result.stdout.strip(), None
|
||||
|
||||
|
||||
def find_binary(names):
|
||||
for name in names:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
for directory in SEARCH_PATHS:
|
||||
candidate = pathlib.Path(directory) / name
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def installed_packages():
|
||||
packages = set()
|
||||
dpkg = find_binary(["dpkg-query"])
|
||||
if dpkg:
|
||||
output, _ = run([dpkg, "-W", "-f=${db:Status-Abbrev} ${binary:Package}\n"])
|
||||
if output:
|
||||
packages.update(line.split(maxsplit=1)[1] for line in output.splitlines() if line.startswith("ii ") and len(line.split(maxsplit=1)) == 2)
|
||||
rpm = find_binary(["rpm"])
|
||||
if rpm:
|
||||
output, _ = run([rpm, "-qa", "--qf", "%{NAME}\n"])
|
||||
if output:
|
||||
packages.update(output.splitlines())
|
||||
apk = find_binary(["apk"])
|
||||
if apk:
|
||||
output, _ = run([apk, "info"])
|
||||
if output:
|
||||
packages.update(output.splitlines())
|
||||
return packages
|
||||
|
||||
|
||||
def package_matches(packages, patterns):
|
||||
return sorted(package for package in packages if any(re.search(pattern, package, re.IGNORECASE) for pattern in patterns))
|
||||
|
||||
|
||||
def systemd_state(units):
|
||||
systemctl = find_binary(["systemctl"])
|
||||
if not systemctl:
|
||||
return None
|
||||
for unit in units:
|
||||
output, error = run([systemctl, "show", unit, "--no-page", "--property=LoadState", "--property=ActiveState", "--property=SubState"])
|
||||
if error or not output:
|
||||
continue
|
||||
values = dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
|
||||
if values.get("LoadState") != "not-found":
|
||||
return {"unit": unit, "load_state": values.get("LoadState"), "active_state": values.get("ActiveState"), "sub_state": values.get("SubState")}
|
||||
return None
|
||||
|
||||
|
||||
def parse_listeners():
|
||||
ss = find_binary(["ss"])
|
||||
if not ss:
|
||||
raise RuntimeError("ss is not installed")
|
||||
output, error = run([ss, "-H", "-ltnup", "( sport = :80 or sport = :443 )"])
|
||||
if error and output is None:
|
||||
raise RuntimeError(error)
|
||||
listeners = []
|
||||
missing_process_details = False
|
||||
for line in (output or "").splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
protocol = fields[0].lower()
|
||||
local_address = fields[4]
|
||||
try:
|
||||
port = int(local_address.rsplit(":", 1)[1])
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
if port not in (80, 443):
|
||||
continue
|
||||
peer_address = fields[5] if len(fields) > 5 else None
|
||||
process_field = " ".join(fields[6:])
|
||||
processes = []
|
||||
for name, pid, descriptor in re.findall(r'\("([^"\\]*(?:\\.[^"\\]*)*)",pid=(\d+),fd=(\d+)\)', process_field):
|
||||
process_name = bytes(name, "utf-8").decode("unicode_escape")
|
||||
processes.append({"name": process_name, "pid": int(pid), "fd": int(descriptor), "command": process_command(pid)})
|
||||
if not processes:
|
||||
missing_process_details = True
|
||||
listeners.append({
|
||||
"protocol": protocol,
|
||||
"state": fields[1].upper(),
|
||||
"local_address": local_address,
|
||||
"peer_address": peer_address,
|
||||
"port": port,
|
||||
"processes": processes,
|
||||
})
|
||||
return listeners, missing_process_details
|
||||
|
||||
|
||||
def process_command(pid):
|
||||
try:
|
||||
raw = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return " ".join(part.decode(errors="replace") for part in raw.split(b"\0") if part) or None
|
||||
|
||||
|
||||
def discover_solutions(packages):
|
||||
solutions = []
|
||||
for definition in SERVER_DEFINITIONS:
|
||||
binary = find_binary(definition["binaries"])
|
||||
matching_packages = package_matches(packages, definition["packages"])
|
||||
unit = systemd_state(definition["units"])
|
||||
installed = bool(binary or matching_packages or unit)
|
||||
state = "Not installed"
|
||||
if installed:
|
||||
state = "Installed"
|
||||
if unit:
|
||||
state = f"Service {unit['active_state'] or 'unknown'}"
|
||||
solutions.append({
|
||||
"name": definition["name"],
|
||||
"installed": installed,
|
||||
"binary": binary,
|
||||
"packages": matching_packages,
|
||||
"service": unit,
|
||||
"status": state,
|
||||
})
|
||||
return solutions
|
||||
|
||||
|
||||
def site_symlinks(directory):
|
||||
root = pathlib.Path(directory)
|
||||
links = []
|
||||
if not root.is_dir():
|
||||
return links
|
||||
for path in sorted(root.iterdir()):
|
||||
if path.is_symlink():
|
||||
try:
|
||||
target = str(path.resolve(strict=True))
|
||||
except OSError:
|
||||
target = os.readlink(path)
|
||||
links.append({"path": str(path), "target": target})
|
||||
return links
|
||||
|
||||
|
||||
def directive_values(block, directive):
|
||||
values = []
|
||||
for line in block.splitlines():
|
||||
line = line.split("#", 1)[0].strip()
|
||||
match = re.match(rf"{re.escape(directive)}\s+(.+?)\s*;?\s*$", line, re.IGNORECASE)
|
||||
if match:
|
||||
values.extend(match.group(1).strip('"\'').split())
|
||||
return values
|
||||
|
||||
|
||||
def urls_for_hosts(hosts, ports):
|
||||
urls = []
|
||||
for host in hosts:
|
||||
if host == "_" or host.startswith("~") or "*" in host:
|
||||
continue
|
||||
for port in sorted(set(ports)):
|
||||
scheme = "https" if port == 443 else "http"
|
||||
suffix = "" if (scheme == "http" and port == 80) or (scheme == "https" and port == 443) else f":{port}"
|
||||
urls.append(f"{scheme}://{host}{suffix}")
|
||||
return urls
|
||||
|
||||
|
||||
def apache_vhosts():
|
||||
control = find_binary(["apache2ctl", "apachectl", "httpd"])
|
||||
output = None
|
||||
control_error = None
|
||||
if control:
|
||||
args = ["-S"] if pathlib.Path(control).name != "httpd" else ["-S"]
|
||||
output, control_error = run([control, *args])
|
||||
links = site_symlinks("/etc/apache2/sites-enabled")
|
||||
sources = []
|
||||
for link in links:
|
||||
target = pathlib.Path(link["target"])
|
||||
if target.is_file():
|
||||
sources.append((target, link["path"]))
|
||||
if not sources:
|
||||
for config_dir in ("/etc/httpd/conf.d", "/etc/apache2/sites-enabled"):
|
||||
root = pathlib.Path(config_dir)
|
||||
if root.is_dir():
|
||||
sources.extend((path.resolve(), str(path) if path.is_symlink() else None) for path in sorted(root.glob("*.conf")) if path.is_file())
|
||||
vhosts = []
|
||||
seen = set()
|
||||
for source, enabled_link in sources:
|
||||
try:
|
||||
text = source.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for address, block in re.findall(r"<VirtualHost\s+([^>]+)>(.*?)</VirtualHost\s*>", text, re.IGNORECASE | re.DOTALL):
|
||||
key = (str(source), address, block)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
hosts = directive_values(block, "ServerName") + directive_values(block, "ServerAlias")
|
||||
ports = [int(value) for value in re.findall(r":(80|443)\b", address)]
|
||||
if not ports:
|
||||
ports = [port for port in (80, 443) if re.search(rf"^\s*Listen\s+(?:[^\s:]+:)?{port}(?:\s|$)", text, re.IGNORECASE | re.MULTILINE)]
|
||||
root_values = directive_values(block, "DocumentRoot")
|
||||
vhosts.append({
|
||||
"server": "Apache HTTP Server",
|
||||
"hosts": hosts,
|
||||
"listen": address.strip(),
|
||||
"ports": ports,
|
||||
"document_root": root_values[0] if root_values else None,
|
||||
"config_file": str(source),
|
||||
"enabled_link": enabled_link,
|
||||
"urls": urls_for_hosts(hosts, ports),
|
||||
})
|
||||
if not vhosts and output:
|
||||
for line in output.splitlines():
|
||||
match = re.match(r"\s*(\S+)\s+(.+?)\s+\((.+):(\d+)\)\s*$", line)
|
||||
if not match:
|
||||
continue
|
||||
address, host, config, _ = match.groups()
|
||||
if not config.startswith("/") or not ("80" in address or "443" in address):
|
||||
continue
|
||||
host = re.sub(r"^(?:default server|namevhost)\s+", "", host)
|
||||
hosts = [] if host in ("_default_", "*", "default server") else [host]
|
||||
ports = [port for port in (80, 443) if re.search(rf":{port}\b", address)]
|
||||
vhosts.append({"server": "Apache HTTP Server", "hosts": hosts, "listen": address, "ports": ports, "document_root": None, "config_file": config, "enabled_link": None, "urls": urls_for_hosts(hosts, ports)})
|
||||
return vhosts, links, control_error
|
||||
|
||||
|
||||
def nginx_vhosts():
|
||||
executable = find_binary(["nginx"])
|
||||
if not executable:
|
||||
return [], [], None
|
||||
output, error = run([executable, "-T"], timeout=20)
|
||||
links = site_symlinks("/etc/nginx/sites-enabled")
|
||||
link_by_target = {}
|
||||
for link in links:
|
||||
link_by_target[os.path.realpath(link["target"])] = link["path"]
|
||||
if not output:
|
||||
return [], links, error
|
||||
vhosts = []
|
||||
current_file = None
|
||||
block_lines = []
|
||||
block_depth = 0
|
||||
in_server = False
|
||||
for line in output.splitlines():
|
||||
file_marker = re.match(r"# configuration file (.+):\s*$", line)
|
||||
if file_marker:
|
||||
current_file = file_marker.group(1)
|
||||
clean = line.split("#", 1)[0]
|
||||
if not in_server and re.search(r"\bserver\s*\{", clean):
|
||||
in_server = True
|
||||
block_lines = [clean]
|
||||
block_depth = clean.count("{") - clean.count("}")
|
||||
elif in_server:
|
||||
block_lines.append(clean)
|
||||
block_depth += clean.count("{") - clean.count("}")
|
||||
if in_server and block_depth <= 0:
|
||||
block = "\n".join(block_lines)
|
||||
hosts = directive_values(block, "server_name")
|
||||
listens = directive_values(block, "listen")
|
||||
roots = directive_values(block, "root")
|
||||
ports = []
|
||||
for listen in listens:
|
||||
port_match = re.search(r"(?:^|\s|:)(80|443)(?:\s|$)", listen)
|
||||
if port_match:
|
||||
ports.append(int(port_match.group(1)))
|
||||
elif not re.search(r"\d", listen):
|
||||
ports.append(80)
|
||||
if hosts or listens:
|
||||
vhosts.append({
|
||||
"server": "Nginx",
|
||||
"hosts": hosts,
|
||||
"listen": ", ".join(listens),
|
||||
"ports": sorted(set(ports)),
|
||||
"document_root": roots[0] if roots else None,
|
||||
"config_file": current_file,
|
||||
"enabled_link": link_by_target.get(os.path.realpath(current_file)) if current_file else None,
|
||||
"urls": urls_for_hosts(hosts, ports),
|
||||
})
|
||||
in_server = False
|
||||
block_lines = []
|
||||
block_depth = 0
|
||||
return vhosts, links, error
|
||||
|
||||
|
||||
def collect_report():
|
||||
packages = installed_packages()
|
||||
solutions = discover_solutions(packages)
|
||||
listeners, missing_process_details = parse_listeners()
|
||||
apache, apache_links, apache_error = apache_vhosts()
|
||||
nginx, nginx_links, nginx_error = nginx_vhosts()
|
||||
warnings = []
|
||||
if missing_process_details:
|
||||
warnings.append("Some listeners do not show process/PID details. Run with sudo to identify services owned by other users.")
|
||||
if apache_error and any(item["name"] == "Apache HTTP Server" and item["installed"] for item in solutions):
|
||||
warnings.append(f"Apache vhost query: {apache_error}")
|
||||
if nginx_error and any(item["name"] == "Nginx" and item["installed"] for item in solutions):
|
||||
warnings.append(f"Nginx config query: {nginx_error}")
|
||||
for listener in listeners:
|
||||
if not listener["processes"]:
|
||||
listener["service_guess"] = None
|
||||
continue
|
||||
names = {process["name"].lower() for process in listener["processes"]}
|
||||
matches = []
|
||||
for solution in solutions:
|
||||
names_to_match = {pathlib.Path(solution["binary"]).name.lower()} if solution["binary"] else set()
|
||||
names_to_match.add(solution["name"].lower().replace(" ", ""))
|
||||
if names.intersection(names_to_match) or any(name.startswith(("apache2", "httpd")) and solution["name"].startswith("Apache") for name in names):
|
||||
matches.append(solution["name"])
|
||||
listener["service_guess"] = matches
|
||||
return {
|
||||
"generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"listeners": listeners,
|
||||
"solutions": solutions,
|
||||
"virtual_hosts": apache + nginx,
|
||||
"enabled_site_links": apache_links + nginx_links,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def render_text(report):
|
||||
lines = ["WEB SERVICE STATUS REPORT", "=========================", f"Generated: {report['generated_at']}", "", "INSTALLED WEB SERVERS / REVERSE PROXIES", "---------------------------------------"]
|
||||
installed = [solution for solution in report["solutions"] if solution["installed"]]
|
||||
if installed:
|
||||
for solution in installed:
|
||||
service = solution.get("service")
|
||||
service_text = f"; {service['unit']} {service['active_state']} ({service['sub_state']})" if service else ""
|
||||
lines.append(f"{solution['name']}: installed{service_text}")
|
||||
if solution["binary"]:
|
||||
lines.append(f" Binary: {solution['binary']}")
|
||||
if solution["packages"]:
|
||||
lines.append(f" Packages: {', '.join(solution['packages'])}")
|
||||
else:
|
||||
lines.append("No known web server or reverse proxy installation detected.")
|
||||
|
||||
lines.extend(["", "LISTENERS ON TCP/UDP PORTS 80 AND 443", "------------------------------------"])
|
||||
if report["listeners"]:
|
||||
for listener in report["listeners"]:
|
||||
process_text = ", ".join(
|
||||
f"{process['name']} (PID {process['pid']})" + (f": {process['command']}" if process.get("command") else "")
|
||||
for process in listener["processes"]
|
||||
) or "process details unavailable"
|
||||
guess = f"; detected service: {', '.join(listener['service_guess'])}" if listener.get("service_guess") else ""
|
||||
lines.append(f"{listener['protocol'].upper()} {listener['local_address']} [{listener['state']}] — {process_text}{guess}")
|
||||
else:
|
||||
lines.append("No listening sockets found on ports 80 or 443.")
|
||||
|
||||
lines.extend(["", "APACHE / NGINX VIRTUAL HOSTS", "----------------------------"])
|
||||
if report["virtual_hosts"]:
|
||||
for vhost in report["virtual_hosts"]:
|
||||
hosts = ", ".join(vhost["hosts"]) or "default / unnamed host"
|
||||
lines.append(f"{vhost['server']}: {hosts}")
|
||||
if vhost.get("listen"):
|
||||
lines.append(f" Listen: {vhost['listen']}")
|
||||
if vhost.get("document_root"):
|
||||
lines.append(f" Document root: {vhost['document_root']}")
|
||||
if vhost.get("config_file"):
|
||||
lines.append(f" Config: {vhost['config_file']}")
|
||||
if vhost.get("enabled_link"):
|
||||
lines.append(f" Enabled link: {vhost['enabled_link']}")
|
||||
for url in vhost["urls"]:
|
||||
lines.append(f" URL: {url}")
|
||||
else:
|
||||
lines.append("No Apache/Nginx virtual hosts could be read, or neither server is installed.")
|
||||
|
||||
lines.extend(["", "ENABLED SITE CONFIGURATION LINKS", "--------------------------------"])
|
||||
links = report["enabled_site_links"]
|
||||
if links:
|
||||
lines.extend(f"{link['path']} -> {link['target']}" for link in links)
|
||||
else:
|
||||
lines.append("No sites-enabled symlinks found.")
|
||||
if report["warnings"]:
|
||||
lines.extend(["", "NOTES", "-----"])
|
||||
lines.extend(f"- {warning}" for warning in report["warnings"])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def render_html(report):
|
||||
def esc(value):
|
||||
return html.escape("Unavailable" if value is None or value == "" else str(value))
|
||||
|
||||
solution_rows = "".join(
|
||||
f"<tr><td>{esc(item['name'])}</td><td>{'Installed' if item['installed'] else 'Not detected'}</td><td>{esc(item.get('service', {}).get('active_state') if item.get('service') else 'Unknown')}</td><td>{esc(item.get('binary'))}</td><td>{esc(', '.join(item['packages']) if item['packages'] else None)}</td></tr>"
|
||||
for item in report["solutions"]
|
||||
)
|
||||
listener_rows = []
|
||||
for item in report["listeners"]:
|
||||
process_text = ", ".join("{} (PID {})".format(proc["name"], proc["pid"]) for proc in item["processes"]) or "Unavailable"
|
||||
service_text = ", ".join(item.get("service_guess") or []) or "Unavailable"
|
||||
listener_rows.append(
|
||||
f"<tr><td>{esc(item['protocol'].upper())}</td><td>{esc(item['local_address'])}</td>"
|
||||
f"<td>{esc(process_text)}</td><td>{esc(service_text)}</td></tr>"
|
||||
)
|
||||
listener_rows = "".join(listener_rows) or "<tr><td colspan=\"4\">No listeners on ports 80/443 found.</td></tr>"
|
||||
host_rows = ""
|
||||
for vhost in report["virtual_hosts"]:
|
||||
urls = "<br>".join(f"<a href=\"{esc(url)}\">{esc(url)}</a>" for url in vhost["urls"])
|
||||
host_rows += f"<tr><td>{esc(vhost['server'])}</td><td>{esc(', '.join(vhost['hosts']) or 'default / unnamed')}</td><td>{esc(vhost['listen'])}</td><td>{esc(vhost['document_root'])}</td><td>{esc(vhost['config_file'])}</td><td>{esc(vhost['enabled_link'])}</td><td>{urls or '—'}</td></tr>"
|
||||
host_rows = host_rows or "<tr><td colspan=\"7\">No virtual hosts could be read.</td></tr>"
|
||||
link_rows = "".join(f"<tr><td>{esc(item['path'])}</td><td>{esc(item['target'])}</td></tr>" for item in report["enabled_site_links"])
|
||||
link_rows = link_rows or "<tr><td colspan=\"2\">No enabled-site symlinks found.</td></tr>"
|
||||
warning_html = "".join(f"<li>{esc(item)}</li>" for item in report["warnings"])
|
||||
return (
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Web Services Report</title>"
|
||||
"<style>:root{--ink:#1c292e;--muted:#61737a;--line:#d8e1e3;--paper:#f2f6f5;--panel:#fff;--teal:#087e80;--red:#a44439}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}main{max-width:1220px;margin:auto;padding:32px 22px 48px}header{border-bottom:2px solid var(--teal);padding-bottom:15px}h1{margin:0;font-size:28px}section{background:var(--panel);border:1px solid var(--line);padding:18px;margin-top:14px}h2{font-size:19px;margin:0 0 14px}.muted,header p{color:var(--muted)}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;font-size:13px}th,td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}th{color:var(--muted);font-size:11px;text-transform:uppercase}a{color:var(--teal)}.warning{border-left:3px solid var(--red);padding-left:12px}@media(max-width:700px){main{padding:20px 12px}section{padding:14px}}</style></head><body><main>"
|
||||
f"<header><h1>Web Server and Reverse Proxy Report</h1><p>Generated {esc(report['generated_at'])}</p></header>"
|
||||
"<section><h2>Installed solutions</h2><div class=\"table-wrap\"><table><thead><tr><th>Solution</th><th>Detected</th><th>Service state</th><th>Binary</th><th>Packages</th></tr></thead><tbody>" + solution_rows + "</tbody></table></div></section>"
|
||||
"<section><h2>Listeners on ports 80 / 443</h2><div class=\"table-wrap\"><table><thead><tr><th>Protocol</th><th>Address</th><th>Process</th><th>Detected service</th></tr></thead><tbody>" + listener_rows + "</tbody></table></div></section>"
|
||||
"<section><h2>Apache / Nginx virtual hosts</h2><div class=\"table-wrap\"><table><thead><tr><th>Server</th><th>Hostnames</th><th>Listen</th><th>Document root</th><th>Config file</th><th>Enabled link</th><th>URLs</th></tr></thead><tbody>" + host_rows + "</tbody></table></div></section>"
|
||||
"<section><h2>Enabled site links</h2><div class=\"table-wrap\"><table><thead><tr><th>Link</th><th>Target</th></tr></thead><tbody>" + link_rows + "</tbody></table></div></section>"
|
||||
+ (f"<section class=\"warning\"><h2>Notes</h2><ul>{warning_html}</ul></section>" if warning_html else "")
|
||||
+ "</main></body></html>\n"
|
||||
)
|
||||
|
||||
|
||||
def ask(prompt, default):
|
||||
try:
|
||||
answer = input(f"{prompt} [{default}]: ").strip()
|
||||
except EOFError:
|
||||
return default
|
||||
return answer or default
|
||||
|
||||
|
||||
def ask_yes_no(prompt, default):
|
||||
marker = "Y/n" if default else "y/N"
|
||||
try:
|
||||
answer = input(f"{prompt} [{marker}]: ").strip().lower()
|
||||
except EOFError:
|
||||
return default
|
||||
if not answer:
|
||||
return default
|
||||
return answer in ("y", "yes")
|
||||
|
||||
|
||||
def infer_format(path, requested):
|
||||
if requested:
|
||||
return requested
|
||||
suffix = pathlib.Path(path).suffix.lower().lstrip(".")
|
||||
return suffix if suffix in ("txt", "json", "html") else "txt"
|
||||
|
||||
|
||||
def render_report(report, format_name):
|
||||
if format_name == "json":
|
||||
return json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
||||
if format_name == "html":
|
||||
return render_html(report)
|
||||
return render_text(report)
|
||||
|
||||
|
||||
def save_report(path_text, format_name, force, interactive, report):
|
||||
path = pathlib.Path(path_text).expanduser()
|
||||
if not path.parent.exists() or not path.parent.is_dir():
|
||||
raise OSError(f"output directory does not exist: {path.parent}")
|
||||
if path.exists() and not force:
|
||||
if not interactive or not ask_yes_no(f"{path} already exists. Overwrite it?", False):
|
||||
return False
|
||||
rendered = render_report(report, format_name)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as output:
|
||||
temporary = pathlib.Path(output.name)
|
||||
output.write(rendered)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
print(f"Report saved: {path}", file=sys.stderr)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
output_path, requested_format, force_value = sys.argv[1:4]
|
||||
force = force_value == "1"
|
||||
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
||||
report = collect_report()
|
||||
if output_path:
|
||||
format_name = infer_format(output_path, requested_format)
|
||||
if not save_report(output_path, format_name, force, interactive, report):
|
||||
print("Report not written; existing file was not overwritten.", file=sys.stderr)
|
||||
return 1 if not interactive else 0
|
||||
return 0
|
||||
if not interactive:
|
||||
print(render_report(report, requested_format or "txt"), end="")
|
||||
return 0
|
||||
print(render_text(report), end="")
|
||||
if not ask_yes_no("Save this report to a file?", True):
|
||||
return 0
|
||||
format_name = requested_format or ask("Report format (txt/json/html)", "txt").lower()
|
||||
if format_name not in ("txt", "json", "html"):
|
||||
print("Unsupported format; using txt.", file=sys.stderr)
|
||||
format_name = "txt"
|
||||
path = ask("Output path", str(pathlib.Path.home() / f"web-services-report.{format_name}"))
|
||||
if not save_report(path, format_name, force, interactive, report):
|
||||
print("Report not written; existing file was not overwritten.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, RuntimeError, json.JSONDecodeError) as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PYCODE
|
||||
)" "$OUTPUT_PATH" "$OUTPUT_FORMAT" "$FORCE"
|
||||
Reference in new issue
Block a user