#!/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"]+)>(.*?)", 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"{esc(item['name'])}{'Installed' if item['installed'] else 'Not detected'}{esc(item.get('service', {}).get('active_state') if item.get('service') else 'Unknown')}{esc(item.get('binary'))}{esc(', '.join(item['packages']) if item['packages'] else None)}" 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"{esc(item['protocol'].upper())}{esc(item['local_address'])}" f"{esc(process_text)}{esc(service_text)}" ) listener_rows = "".join(listener_rows) or "No listeners on ports 80/443 found." host_rows = "" for vhost in report["virtual_hosts"]: urls = "
".join(f"{esc(url)}" for url in vhost["urls"]) host_rows += f"{esc(vhost['server'])}{esc(', '.join(vhost['hosts']) or 'default / unnamed')}{esc(vhost['listen'])}{esc(vhost['document_root'])}{esc(vhost['config_file'])}{esc(vhost['enabled_link'])}{urls or '—'}" host_rows = host_rows or "No virtual hosts could be read." link_rows = "".join(f"{esc(item['path'])}{esc(item['target'])}" for item in report["enabled_site_links"]) link_rows = link_rows or "No enabled-site symlinks found." warning_html = "".join(f"
  • {esc(item)}
  • " for item in report["warnings"]) return ( "Web Services Report" "
    " f"

    Web Server and Reverse Proxy Report

    Generated {esc(report['generated_at'])}

    " "

    Installed solutions

    " + solution_rows + "
    SolutionDetectedService stateBinaryPackages
    " "

    Listeners on ports 80 / 443

    " + listener_rows + "
    ProtocolAddressProcessDetected service
    " "

    Apache / Nginx virtual hosts

    " + host_rows + "
    ServerHostnamesListenDocument rootConfig fileEnabled linkURLs
    " "

    Enabled site links

    " + link_rows + "
    LinkTarget
    " + (f"

    Notes

      {warning_html}
    " if warning_html else "") + "
    \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"