"""Squid configuration (squid.conf) parser and generator. Strategy: - Parse the existing squid.conf into a list of directives with comments - Expose structured getters for sections (network, cache, ACLs, rules, auth, ...) - Allow web UI to update specific directives - Generate a complete, well-formed squid.conf from the structured config - Validate before applying (run `squid -k parse` if available) Directive model: - Each directive is a tuple (key, [args...], comment) - Directives are grouped into named sections for the UI Note: Squid supports many directives. We cover the most common ones used in production deployments. A "raw editor" tab is always available for anything we don't model explicitly. """ from __future__ import annotations import os import re import subprocess from dataclasses import dataclass, field from typing import Optional @dataclass class Directive: """A single squid.conf directive line, possibly with a comment.""" key: str args: list[str] comment: str = "" # raw original line (for round-trip preservation) raw: str = "" def render(self) -> str: if self.raw and not self.args and not self.comment: return self.raw line = self.key if self.args: line += " " + " ".join(self.args) if self.comment: line += " #" + self.comment return line @dataclass class Acl: """An ACL entry: acl ...""" name: str type: str values: list[str] comment: str = "" def render(self) -> str: parts = ["acl", self.name, self.type] + self.values line = " ".join(parts) if self.comment: line += " #" + self.comment return line @dataclass class Rule: """An access rule: http_access [!]acl...""" action: str # allow / deny acls: list[str] # e.g. ["!localhost", "allowed_net"] comment: str = "" directive: str = "http_access" # http_access, https_access, icp_access, ... def render(self) -> str: parts = [self.directive, self.action] + self.acls line = " ".join(parts) if self.comment: line += " #" + self.comment return line @dataclass class CacheDir: """A cache_dir directive: cache_dir [options]""" type: str = "ufs" path: str = "/var/spool/squid" size_mb: int = 100 l1: int = 16 l2: int = 256 options: list[str] = field(default_factory=list) def render(self) -> str: parts = ["cache_dir", self.type, self.path, str(self.size_mb), str(self.l1), str(self.l2)] parts.extend(self.options) return " ".join(parts) @dataclass class RefreshPattern: """refresh_pattern [-i] regex min percent max [{options}]""" case_insensitive: bool = False regex: str = "." min_minutes: int = 0 percent: int = 20 max_minutes: int = 4320 options: list[str] = field(default_factory=list) def render(self) -> str: parts = ["refresh_pattern"] if self.case_insensitive: parts.append("-i") parts.extend([self.regex, str(self.min_minutes), f"{self.percent}%", str(self.max_minutes)]) if self.options: parts.append("{" + " ".join(self.options) + "}") return " ".join(parts) @dataclass class AuthParam: """auth_param ...""" scheme: str = "basic" program: str = "/usr/lib/squid/basic_ncsa_auth /etc/squid/passwd" children: int = 5 realm: str = "Squid Proxy" credentialsttl: int = 2 # also stored as raw param dict for other schemes extra: dict = field(default_factory=dict) def render_all(self) -> list[str]: lines = [] lines.append(f"auth_param {self.scheme} program {self.program}") lines.append(f"auth_param {self.scheme} children {self.children}") lines.append(f"auth_param {self.scheme} realm {self.realm}") lines.append(f"auth_param {self.scheme} credentialsttl {self.credentialsttl} hours") for k, v in self.extra.items(): lines.append(f"auth_param {self.scheme} {k} {v}") return lines @dataclass class CachePeer: """cache_peer [options]""" host: str = "" type: str = "parent" http_port: int = 3128 icp_port: int = 0 options: list[str] = field(default_factory=list) def render(self) -> str: parts = ["cache_peer", self.host, self.type, str(self.http_port), str(self.icp_port)] parts.extend(self.options) return " ".join(parts) @dataclass class SslBumpRule: """ssl_bump directive: ssl_bump [!]acl ... action: peek, splice, bump, terminate, client-first, server-first, none """ action: str = "peek" acls: list[str] = field(default_factory=list) # ACL names + optional ! negation step: int = 1 # rule order comment: str = "" def render(self) -> str: parts = ["ssl_bump", self.action] + self.acls line = " ".join(parts) if self.comment: line += " #" + self.comment return line # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- def parse_conf(text: str): """Parse a squid.conf string into structured groups. Returns dict with keys: directives (raw), acls, rules, cache_dirs, refresh_patterns, auth_params, cache_peers, ssl_bump_rules. """ acls = [] rules = [] cache_dirs = [] refresh_patterns = [] cache_peers = [] auth_lines = [] # raw auth_param lines, parsed into AuthParam later ssl_bump_rules = [] # ssl_bump directives # map of directive -> list of (args, comment) for simple single-value directives simple: dict[str, list[tuple[list[str], str]]] = {} for raw in text.splitlines(): line = raw.strip() if not line: continue if line.startswith("#"): continue # split off trailing comment comment = "" if " #" in line: line, _, comment = line.partition(" #") line = line.rstrip() comment = comment.strip() tokens = line.split() if not tokens: continue key = tokens[0] args = tokens[1:] if key == "acl": if len(args) >= 3: acls.append(Acl( name=args[0], type=args[1], values=args[2:], comment=comment, )) continue if key in ("http_access", "http_access2", "https_access", "icp_access", "htcp_access", "miss_access", "snmp_access", "ident_lookup_access", "always_direct", "never_direct", "reply_body_max_size", "reply_access"): if args: rules.append(Rule( action=args[0], acls=args[1:], comment=comment, directive=key, )) continue if key == "cache_dir": if len(args) >= 5: cache_dirs.append(CacheDir( type=args[0], path=args[1], size_mb=int(args[2]) if args[2].isdigit() else 100, l1=int(args[3]) if args[3].isdigit() else 16, l2=int(args[4]) if args[4].isdigit() else 256, options=args[5:], )) continue if key == "refresh_pattern": rp = RefreshPattern() idx = 0 if idx < len(args) and args[idx] == "-i": rp.case_insensitive = True idx += 1 if idx < len(args): rp.regex = args[idx]; idx += 1 if idx < len(args) and args[idx].lstrip("-").isdigit(): rp.min_minutes = int(args[idx]); idx += 1 if idx < len(args) and args[idx].endswith("%"): try: rp.percent = int(args[idx].rstrip("%")) except ValueError: pass idx += 1 if idx < len(args) and args[idx].lstrip("-").isdigit(): rp.max_minutes = int(args[idx]); idx += 1 rp.options = args[idx:] refresh_patterns.append(rp) continue if key == "cache_peer": if len(args) >= 4: cache_peers.append(CachePeer( host=args[0], type=args[1], http_port=int(args[2]) if args[2].isdigit() else 3128, icp_port=int(args[3]) if args[3].isdigit() else 0, options=args[4:], )) continue if key == "ssl_bump": # ssl_bump [!]acl... if args: action = args[0] acls_for_rule = args[1:] step = len(ssl_bump_rules) + 1 ssl_bump_rules.append(SslBumpRule( action=action, acls=acls_for_rule, step=step, comment=comment, )) continue if key == "auth_param": auth_lines.append((args, comment)) continue simple.setdefault(key, []).append((args, comment)) # parse auth_params into structured form (one per scheme) auth_params = parse_auth_lines(auth_lines) return { "acls": acls, "rules": rules, "cache_dirs": cache_dirs, "refresh_patterns": refresh_patterns, "cache_peers": cache_peers, "auth_params": auth_params, "ssl_bump_rules": ssl_bump_rules, "simple": simple, # raw simple directives, list of (args, comment) } def parse_auth_lines(lines: list[tuple[list[str], str]]) -> list[AuthParam]: """Group auth_param lines by scheme.""" by_scheme: dict[str, AuthParam] = {} for args, comment in lines: if len(args) < 2: continue scheme = args[0] param = args[1] value = " ".join(args[2:]) if scheme not in by_scheme: ap = AuthParam(scheme=scheme) by_scheme[scheme] = ap ap = by_scheme[scheme] if param == "program": ap.program = value elif param == "children": try: ap.children = int(args[2]) except (ValueError, IndexError): pass elif param == "realm": ap.realm = value elif param == "credentialsttl": try: ap.credentialsttl = int(args[2]) except (ValueError, IndexError): pass else: ap.extra[param] = value return list(by_scheme.values()) # --------------------------------------------------------------------------- # Generation # --------------------------------------------------------------------------- # default simple directives the UI manages explicitly. DEFAULT_SIMPLE = { # network "http_port": [["3128"], ""], "https_port": [], "icp_port": [["3130"], ""], "snmp_port": [], "visible_hostname": [["localhost"], ""], "unique_hostname": [], "hostname_aliases": [], # cache "cache_mem": [["256", "MB"], ""], "maximum_object_size": [["4096", "KB"], ""], "minimum_object_size": [["0", "KB"], ""], "maximum_object_size_in_memory": [["512", "KB"], ""], "cache_replacement_policy": [["lru"], ""], "memory_replacement_policy": [["lru"], ""], "cache_swap_low": [["90"], ""], "cache_swap_high": [["95"], ""], # logging "access_log": [["/var/log/squid/access.log"], ""], "cache_log": [["/var/log/squid/cache.log"], ""], "cache_store_log": [["/var/log/squid/store.log"], ""], "pid_filename": [["/var/run/squid.pid"], ""], "logformat": [], "debug_options": [], "log_ip_on_direct": [["on"], ""], "buffered_logs": [["on"], ""], # dns "dns_nameservers": [], "dns_timeout": [["30", "seconds"], ""], "hosts_file": [["/etc/hosts"], ""], "dns_defnames": [["off"], ""], # SSL / TLS (for ssl-bump) "sslcrtd_program": [["security_file_certgen", "-s", "/var/lib/squid/ssl_db", "-M", "4", "MB"], ""], "sslcrtd_children": [["5", "startup=1", "idle=1"], ""], "sslproxy_ca_certfile": [], "sslproxy_ca_keyfile": [], "tls_outgoing_version": [], # timeouts "connect_timeout": [["60", "seconds"], ""], "read_timeout": [["15", "minutes"], ""], "client_lifetime": [["1", "day"], ""], "shutdown_lifetime": [["30", "seconds"], ""], # admin "cache_mgr": [["root"], ""], "err_html_language": [["en"], ""], "ftp_user": [], } def generate_conf(struct: dict, raw_extra: str = "") -> str: """Generate a complete squid.conf string from the structured config.""" lines: list[str] = [] lines.append("# ============================================================") lines.append("# squid.conf - managed by Squid Web Manager") lines.append("# WARNING: this file is regenerated by the web UI.") lines.append("# Manual edits may be lost - use the Raw Editor tab") lines.append("# if you need to add directives not covered by the UI.") lines.append("# ============================================================") lines.append("") # --- Network --- lines.append("# ---- Network ----") for key in ("http_port", "https_port", "icp_port", "snmp_port", "visible_hostname", "unique_hostname", "hostname_aliases"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) lines.append("") # --- Cache --- lines.append("# ---- Cache ----") for key in ("cache_mem", "maximum_object_size", "minimum_object_size", "maximum_object_size_in_memory", "cache_replacement_policy", "memory_replacement_policy", "cache_swap_low", "cache_swap_high"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) for cd in struct["cache_dirs"]: lines.append(cd.render()) lines.append("") # --- Auth --- if struct["auth_params"]: lines.append("# ---- Authentication ----") for ap in struct["auth_params"]: for ln in ap.render_all(): lines.append(ln) lines.append("") # --- ACLs --- lines.append("# ---- ACLs ----") for acl in struct["acls"]: lines.append(acl.render()) lines.append("") # --- Refresh patterns --- if struct["refresh_patterns"]: lines.append("# ---- Refresh patterns ----") for rp in struct["refresh_patterns"]: lines.append(rp.render()) lines.append("") # --- Cache peers --- if struct["cache_peers"]: lines.append("# ---- Cache peers ----") for cp in struct["cache_peers"]: lines.append(cp.render()) lines.append("") # --- Access rules --- lines.append("# ---- Access rules ----") for r in struct["rules"]: lines.append(r.render()) lines.append("") # --- Logging --- lines.append("# ---- Logging ----") for key in ("access_log", "cache_log", "cache_store_log", "pid_filename", "logformat", "debug_options", "log_ip_on_direct", "buffered_logs"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) lines.append("") # --- DNS --- lines.append("# ---- DNS ----") for key in ("dns_nameservers", "dns_timeout", "hosts_file", "dns_defnames"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) lines.append("") # --- SSL/TLS --- ssl_keys = ("sslcrtd_program", "sslcrtd_children", "sslproxy_ca_certfile", "sslproxy_ca_keyfile", "tls_outgoing_version") if any(struct["simple"].get(k) for k in ssl_keys) or struct.get("ssl_bump_rules"): lines.append("# ---- SSL/TLS ----") for key in ssl_keys: for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) for rule in struct.get("ssl_bump_rules", []): lines.append(rule.render()) lines.append("") # --- Timeouts --- lines.append("# ---- Timeouts ----") for key in ("connect_timeout", "read_timeout", "client_lifetime", "shutdown_lifetime"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) lines.append("") # --- Admin --- lines.append("# ---- Admin ----") for key in ("cache_mgr", "err_html_language", "ftp_user"): for args, comment in struct["simple"].get(key, []): lines.append(_render_simple(key, args, comment)) lines.append("") # --- raw extra directives --- if raw_extra.strip(): lines.append("# ---- Raw directives (manual) ----") for ln in raw_extra.splitlines(): if ln.strip(): lines.append(ln.strip()) lines.append("") return "\n".join(lines) + "\n" def _render_simple(key: str, args: list[str], comment: str = "") -> str: line = key + (" " + " ".join(args) if args else "") if comment: line += " #" + comment return line # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- def validate_conf(text: str, squid_binary: str = "squid") -> tuple[bool, str]: """Run `squid -k parse` on the given config text. Returns (ok, message). Falls back to syntax-only check if squid isn't installed. """ if not squid_binary or not _which(squid_binary): # no squid binary available - just check that we can parse it try: parse_conf(text) return True, "Squid binary not found - parsed structure successfully " \ "(skipped runtime syntax check)." except Exception as e: return False, f"Parse error: {e}" tmp_path = "/tmp/squid_conf_check.conf" try: with open(tmp_path, "w") as f: f.write(text) except OSError as e: return False, f"Cannot write temp file: {e}" try: r = subprocess.run( [squid_binary, "-k", "parse", "-f", tmp_path], capture_output=True, text=True, timeout=10, ) except subprocess.TimeoutExpired: return False, "squid -k parse timed out" except FileNotFoundError: return False, "squid binary not found" # squid -k parse exits 0 on success. Errors are written to stdout. out = (r.stdout or "").strip() err = (r.stderr or "").strip() msg = out + ("\n" + err if err else "") # squid typically prints "WARNING:" lines for non-fatal issues. if r.returncode == 0: return True, msg or "Configuration OK" return False, msg or f"squid -k parse failed (exit {r.returncode})" def _which(cmd: str) -> Optional[str]: """Lightweight which() that doesn't depend on shutil.which.""" for path in os.environ.get("PATH", "").split(":"): if not path: continue candidate = os.path.join(path, cmd) if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate return None # --------------------------------------------------------------------------- # Defaults (used to bootstrap a brand new install) # --------------------------------------------------------------------------- def default_struct() -> dict: """Return a default structured config for a fresh install.""" simple = {k: [v] if isinstance(v, list) and v and isinstance(v[0], list) else (v if isinstance(v, list) else []) for k, v in DEFAULT_SIMPLE.items()} # DEFAULT_SIMPLE values are already lists of [args, comment] # but they are stored as [args_list, comment] tuples - normalize. simple = {} for k, v in DEFAULT_SIMPLE.items(): # v is [[args...], comment] or [] (empty) if isinstance(v, list) and len(v) == 2 and isinstance(v[0], list): simple[k] = [(v[0], v[1])] else: simple[k] = [] # Provide a sensible default sslcrtd_program bootstrap so freshly-deployed # SSL Bump setups have a working cert generator out of the box. # (Empty list would render "sslcrtd_program" with no args - leave it.) # Default is intentionally commented out in DEFAULT_SIMPLE above # (sslcrtd_program: []). Users override via the SSL Bump UI. return { "acls": [ Acl(name="localhost", type="src", values=["127.0.0.1/32"]), Acl(name="to_localhost", type="dst", values=["127.0.0.0/8"]), Acl(name="safeports", type="port", values=["80", "443", "21", "70", "80-1024"]), Acl(name="sslports", type="port", values=["443", "563"]), Acl(name="CONNECT", type="method", values=["CONNECT"]), Acl(name="all", type="src", values=["0.0.0.0/0"]), Acl(name="manager", type="proto", values=["cache_object"]), ], "rules": [ Rule(action="deny", acls=["manager"], directive="http_access"), Rule(action="deny", acls=["!safeports"], directive="http_access"), Rule(action="deny", acls=["!sslports"], directive="http_access"), Rule(action="allow", acls=["localhost"], directive="http_access"), Rule(action="deny", acls=["all"], directive="http_access"), ], "cache_dirs": [ CacheDir(type="ufs", path="/var/spool/squid", size_mb=100, l1=16, l2=256) ], "refresh_patterns": [ RefreshPattern(regex="^ftp:", min_minutes=1440, percent=20, max_minutes=10080), RefreshPattern(regex="^gopher:", min_minutes=1440, percent=0, max_minutes=1440), RefreshPattern(case_insensitive=True, regex=" cgi-bin", min_minutes=0, percent=0, max_minutes=0), RefreshPattern(regex=".", min_minutes=0, percent=20, max_minutes=4320), ], "cache_peers": [], "auth_params": [], "ssl_bump_rules": [], "simple": simple, } ACL_TYPES = [ ("src", "Source IP/CIDR (src 192.168.1.0/24)"), ("dst", "Destination IP/CIDR"), ("dstdomain", "Destination domain (matches *.example.com)"), ("dstdom_regex", "Destination domain regex"), ("srcdomain", "Source domain"), ("srcdom_regex", "Source domain regex"), ("port", "Destination port"), ("method", "HTTP method (GET, POST, CONNECT, ...)"), ("proto", "Protocol (http, https, ftp, ...)"), ("time", "Time of day/week (SMTWHFA/HH:MM-HH:MM)"), ("url_regex", "URL regex"), ("urlpath_regex", "URL path regex"), ("url_login", "URL login part regex"), ("browser", "User-Agent regex"), ("referer_regex", "Referer regex"), ("ident", "User ident string"), ("ident_regex", "User ident regex"), ("proxy_auth", "Authenticated user / REQUIRED"), ("proxy_auth_regex", "Authenticated user regex"), ("maxconn", "Max connections per client"), ("max_user_ip", "Max IPs per user"), ("rep_mime_type", "Reply MIME type regex"), ("req_header", "Request header"), ("rep_header", "Reply header"), ("localip", "Local IP"), ("localnet", "Local network"), ("peername", "Peer name"), ("hierarchy_type", "Hierarchy type"), ] RULE_DIRECTIVES = [ "http_access", "http_access2", "https_access", "icp_access", "htcp_access", "miss_access", "snmp_access", "ident_lookup_access", "reply_access", "adaptation_access", ] # Valid ssl_bump actions (Squid docs - https://www.squid-cache.org/Doc/config/ssl_bump/) SSL_BUMP_ACTIONS = [ "peek", "splice", "bump", "terminate", "client-first", "server-first", "none", ] SSL_BUMP_DEFAULT_EXAMPLE = ( "# Suggested starter set (rule order matters - top to bottom):\n" "# ssl_bump peek step1 # peek at TLS client hello (step 1)\n" "# ssl_bump splice all # otherwise pass-through tunnel\n" "#\n" "# Required prereqs before SSL Bump will work:\n" "# 1) sslcrtd_program/children pointing at security_file_certgen\n" "# and an initialised ssl_db directory\n" "# 2) sslproxy_ca_certfile / sslproxy_ca_keyfile pointing at a CA\n" "# that your client browsers trust (or you distribute the cert\n" "# and instruct users to install it)\n" "# 3) https_port configured with ssl-bump option" )