mirror of
https://gitlab.com/openconnect/ocserv.git
synced 2026-08-08 09:21:48 +08:00
All [scope: vhost (non-reloadable)] fields in static_cfg_st now inherit from the default vhost when not explicitly set in a named-vhost section, consistent with how ReloadableConfig fields already behave. This means a named vhost that shares the same TLS cert, CA, auth method, or PKCS#11 pins as the default no longer has to repeat them. Signed-off-by: Nikos Mavrogiannopoulos <n.mavrogiannopoulos@gmail.com>
391 lines
15 KiB
Python
391 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Validate that [scope:] annotations in doc/sample.config are consistent
|
|
with the actual config parsing code in src/config.c and src/sup-config/file.c.
|
|
|
|
Scope vocabulary:
|
|
global (non-reloadable) -- static_cfg_st; requires restart; cannot differ per vhost
|
|
vhost (non-reloadable) -- static_cfg_st; requires restart; can differ per vhost
|
|
global -- cfg_st; reloadable; cannot be set in [vhost:] sections
|
|
vhost -- cfg_st; reloadable; can differ per vhost
|
|
vhost user -- cfg_st; reloadable; per-vhost AND per-user/group overridable
|
|
|
|
Checks performed:
|
|
(a) Every option in sample.config has a [scope: ...] annotation.
|
|
(b) Every option annotated [... global] has an error_on_vhost() call in
|
|
config.c (or is a permanent-global option in static_cfg_st, which never
|
|
reaches error_on_vhost).
|
|
(c) Every error_on_vhost() option in config.c is annotated [... global] in
|
|
sample.config.
|
|
(d) Every option annotated [...user...] is handled in src/sup-config/file.c.
|
|
(e) Every option in src/sup-config/file.c is annotated [...user...] in
|
|
sample.config.
|
|
(f) Every option in sample.config annotated [...user...] is also annotated
|
|
[...vhost...] (user-overridable options must also be settable per-vhost).
|
|
(g) Every field in struct cfg_st and struct static_cfg_st in src/vpn.h has a
|
|
[scope: ...] inline comment.
|
|
(h) Every [scope: vhost (non-reloadable)] field in static_cfg_st is referenced
|
|
in vhost_inherit_static_config() or listed in STATIC_VHOST_INHERIT_EXCEPTIONS.
|
|
|
|
Exit code: 0 on success, 1 if any errors are found.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import os
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(SCRIPT_DIR)
|
|
|
|
SAMPLE_CONFIG = os.path.join(ROOT, "doc", "sample.config")
|
|
CONFIG_C = os.path.join(ROOT, "src", "config.c")
|
|
SUP_CONFIG_FILE = os.path.join(ROOT, "src", "sup-config", "file.c")
|
|
VPN_H = os.path.join(ROOT, "src", "vpn.h")
|
|
|
|
|
|
def read_file(path):
|
|
with open(path, "r") as f:
|
|
return f.read()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 1: Parse doc/sample.config
|
|
# Returns dict: {option_name: scope_string or None}
|
|
# ---------------------------------------------------------------------------
|
|
def extract_sample_config_scopes(text):
|
|
"""
|
|
For each option line (has '=' and is not a pure comment/blank),
|
|
look backwards for the nearest '# [scope: ...]' line.
|
|
"""
|
|
lines = text.splitlines()
|
|
scopes = {}
|
|
|
|
for i, line in enumerate(lines):
|
|
# Strip leading '#' and whitespace to get the raw content
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("#"):
|
|
after_hash = stripped[1:]
|
|
# Only treat as a potential option if 0 or 1 spaces follow '#'.
|
|
# Two or more spaces indicate an example buried inside a prose
|
|
# comment block (e.g. "# CN = 2.5.4.3" or "# kdc = ...").
|
|
if after_hash.startswith(" "):
|
|
continue
|
|
stripped = after_hash.lstrip()
|
|
|
|
# Is it an option line? Must contain ' = ' or '= ' and not be empty
|
|
if "=" not in stripped or stripped.startswith("["):
|
|
continue
|
|
# Skip pure comment lines (the line itself starts with '#' then a word)
|
|
raw = line.lstrip()
|
|
if raw == "" or raw.startswith("###"):
|
|
continue
|
|
# Extract option name: everything before the first '='
|
|
eq_pos = stripped.find("=")
|
|
opt = stripped[:eq_pos].strip()
|
|
# Reject options that look like key=value inside a comment or contain spaces badly
|
|
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', opt):
|
|
continue
|
|
|
|
# Avoid duplicates — keep first occurrence (the active/commented-first)
|
|
if opt in scopes:
|
|
continue
|
|
|
|
# Walk backwards looking for a '# [scope: ...]' tag
|
|
scope = None
|
|
for j in range(i - 1, max(i - 10, -1), -1):
|
|
prev = lines[j].strip()
|
|
m = re.match(r'^#\s*\[scope:\s*([^\]]+)\]', prev)
|
|
if m:
|
|
scope = m.group(1).strip()
|
|
break
|
|
# Stop if we hit a blank line or a non-comment line
|
|
if prev == "" or (prev and not prev.startswith("#")):
|
|
break
|
|
|
|
scopes[opt] = scope
|
|
|
|
return scopes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 2: Parse src/config.c — options that call error_on_vhost()
|
|
# ---------------------------------------------------------------------------
|
|
def extract_error_on_vhost_options(text):
|
|
"""Return set of option names passed to error_on_vhost()."""
|
|
# Pattern: error_on_vhost(vhost->name, "option-name")
|
|
pattern = re.compile(r'error_on_vhost\s*\([^,]+,\s*"([^"]+)"')
|
|
return set(pattern.findall(text))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 3: Parse src/sup-config/file.c — options handled per-user/group
|
|
# ---------------------------------------------------------------------------
|
|
def extract_sup_config_options(text):
|
|
"""Return set of option names handled in the per-user/group file parser."""
|
|
pattern = re.compile(r'strcmp\s*\(\s*name\s*,\s*"([^"]+)"')
|
|
return set(pattern.findall(text))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 4: Parse src/vpn.h — check that cfg_st and static_cfg_st fields
|
|
# all have [scope: ...] annotations.
|
|
# ---------------------------------------------------------------------------
|
|
def check_vpn_h_annotations(text):
|
|
"""
|
|
Returns list of field declarations in cfg_st and static_cfg_st
|
|
that are missing a [scope: ...] inline comment.
|
|
"""
|
|
missing = []
|
|
|
|
# Find cfg_st body
|
|
for struct_name in ("struct cfg_st", "struct static_cfg_st"):
|
|
m = re.search(re.escape(struct_name) + r'\s*\{', text)
|
|
if not m:
|
|
continue
|
|
start = m.end()
|
|
# Find matching closing brace
|
|
depth = 1
|
|
pos = start
|
|
while pos < len(text) and depth > 0:
|
|
if text[pos] == '{':
|
|
depth += 1
|
|
elif text[pos] == '}':
|
|
depth -= 1
|
|
pos += 1
|
|
body = text[start:pos - 1]
|
|
|
|
# Find field declarations: lines ending in ';' that look like declarations
|
|
for line in body.splitlines():
|
|
stripped = line.strip()
|
|
# Skip blank, comment-only, preprocessor, and struct/union lines
|
|
if (not stripped or stripped.startswith("/*") or stripped.startswith("//")
|
|
or stripped.startswith("#") or stripped.startswith("}")
|
|
or stripped.startswith("{") or stripped.startswith("struct ")
|
|
or stripped.startswith("typedef ")):
|
|
continue
|
|
# Must end with ';'
|
|
if not stripped.endswith(";"):
|
|
continue
|
|
# Skip lines that are closing braces with semicolons e.g. '} name;'
|
|
if stripped.startswith("}"):
|
|
continue
|
|
# Check for [scope: ...] annotation
|
|
if "[scope:" not in line:
|
|
# Extract a clean field name for reporting
|
|
# Remove array/pointer noise
|
|
name_m = re.search(r'\b(\w+)\s*(?:\[[^\]]*\])?\s*;', stripped)
|
|
field = name_m.group(1) if name_m else stripped[:40]
|
|
missing.append(f" {struct_name}: field '{field}' has no [scope:] annotation")
|
|
|
|
return missing
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 5: Parse vhost_inherit_static_config() body — fields it references
|
|
# ---------------------------------------------------------------------------
|
|
def extract_static_inherit_fields(text):
|
|
"""
|
|
Return the set of static_cfg_st field names referenced inside
|
|
vhost_inherit_static_config() via 'static_config.<field>'.
|
|
"""
|
|
m = re.search(r'vhost_inherit_static_config\s*\([^)]+\)\s*\{', text)
|
|
if not m:
|
|
return set()
|
|
start = m.end()
|
|
depth = 1
|
|
pos = start
|
|
while pos < len(text) and depth > 0:
|
|
if text[pos] == '{':
|
|
depth += 1
|
|
elif text[pos] == '}':
|
|
depth -= 1
|
|
pos += 1
|
|
body = text[start:pos - 1]
|
|
# Match both direct access (static_config.field) and VHOST_INHERIT* macros.
|
|
direct = set(re.findall(r'static_config\.(\w+)', body))
|
|
macros = set(re.findall(r'VHOST_INHERIT\w*\s*\(\s*(\w+)\s*\)', body))
|
|
return direct | macros
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 6: Parse static_cfg_st in vpn.h — vhost (non-reloadable) field names
|
|
# ---------------------------------------------------------------------------
|
|
def extract_static_vhost_fields(text):
|
|
"""
|
|
Return the set of field names in static_cfg_st annotated
|
|
[scope: vhost (non-reloadable)].
|
|
"""
|
|
m = re.search(r'struct static_cfg_st\s*\{', text)
|
|
if not m:
|
|
return set()
|
|
start = m.end()
|
|
depth = 1
|
|
pos = start
|
|
while pos < len(text) and depth > 0:
|
|
if text[pos] == '{':
|
|
depth += 1
|
|
elif text[pos] == '}':
|
|
depth -= 1
|
|
pos += 1
|
|
body = text[start:pos - 1]
|
|
|
|
fields = set()
|
|
for line in body.splitlines():
|
|
if '[scope: vhost (non-reloadable)]' not in line:
|
|
continue
|
|
name_m = re.search(r'\b(\w+)\s*(?:\[[^\]]*\])?\s*;', line.strip())
|
|
if name_m:
|
|
fields.add(name_m.group(1))
|
|
return fields
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main validation
|
|
# ---------------------------------------------------------------------------
|
|
def main():
|
|
errors = []
|
|
|
|
sample_text = read_file(SAMPLE_CONFIG)
|
|
config_c_text = read_file(CONFIG_C)
|
|
sup_config_text = read_file(SUP_CONFIG_FILE)
|
|
vpn_h_text = read_file(VPN_H)
|
|
|
|
# Parse
|
|
sample_scopes = extract_sample_config_scopes(sample_text)
|
|
global_in_code = extract_error_on_vhost_options(config_c_text)
|
|
user_in_code = extract_sup_config_options(sup_config_text)
|
|
|
|
# Non-reloadable options live in static_cfg_st and never go through
|
|
# error_on_vhost(); they are enforced structurally. Collect from sample.config.
|
|
perm_global_opts = {
|
|
opt for opt, scope in sample_scopes.items()
|
|
if scope and "non-reloadable" in scope
|
|
}
|
|
|
|
# (a) All options in sample.config have a scope annotation
|
|
for opt, scope in sorted(sample_scopes.items()):
|
|
if scope is None:
|
|
errors.append(
|
|
f"sample.config: '{opt}' has no [scope:] annotation"
|
|
)
|
|
|
|
# (b) [global] options (reloadable only) must have error_on_vhost in config.c;
|
|
# [global (non-reloadable)] options live in static_cfg_st and are exempt.
|
|
for opt, scope in sorted(sample_scopes.items()):
|
|
if scope and "global" in scope and "non-reloadable" not in scope:
|
|
if opt not in global_in_code:
|
|
errors.append(
|
|
f"sample.config: '{opt}' is annotated [{scope}] but "
|
|
f"has no error_on_vhost() call in config.c"
|
|
)
|
|
|
|
# Deprecated aliases that map to a canonical global option and call
|
|
# error_on_vhost() but intentionally have no sample.config entry.
|
|
DEPRECATED_GLOBAL_ALIASES = {
|
|
"use-seccomp", # replaced by isolate-workers
|
|
"min-reauth-time", # replaced by ban-time
|
|
"use-dbus", # replaced by use-occtl
|
|
}
|
|
|
|
# (c) Every error_on_vhost option must be annotated [... global] in sample.config
|
|
for opt in sorted(global_in_code):
|
|
if opt in DEPRECATED_GLOBAL_ALIASES:
|
|
continue
|
|
scope = sample_scopes.get(opt)
|
|
if scope is None:
|
|
errors.append(
|
|
f"config.c: error_on_vhost('{opt}') but option is missing "
|
|
f"from sample.config annotations"
|
|
)
|
|
elif "global" not in scope:
|
|
errors.append(
|
|
f"config.c: error_on_vhost('{opt}') but sample.config "
|
|
f"annotates it [{scope}] (expected '... global')"
|
|
)
|
|
|
|
# (d) [...user...] options must be in sup-config/file.c
|
|
for opt, scope in sorted(sample_scopes.items()):
|
|
if scope and "user" in scope:
|
|
if opt not in user_in_code:
|
|
errors.append(
|
|
f"sample.config: '{opt}' is annotated [{scope}] but is not "
|
|
f"handled in src/sup-config/file.c"
|
|
)
|
|
|
|
# (e) Every option in sup-config/file.c must be annotated [...user...] in sample.config
|
|
# Some options (like 'iroute', 'hostname', 'explicit-ipv4', 'explicit-ipv6')
|
|
# are per-user only and don't appear in the global sample.config — skip those.
|
|
PER_USER_ONLY = {
|
|
"iroute", "hostname", "explicit-ipv4", "explicit-ipv6",
|
|
"ipv4-dns", "ipv6-dns", "ipv4-nbns", "ipv6-nbns",
|
|
}
|
|
for opt in sorted(user_in_code):
|
|
if opt in PER_USER_ONLY:
|
|
continue
|
|
scope = sample_scopes.get(opt)
|
|
if scope is None:
|
|
errors.append(
|
|
f"sup-config/file.c: handles '{opt}' but option is missing "
|
|
f"from sample.config annotations"
|
|
)
|
|
elif "user" not in scope:
|
|
errors.append(
|
|
f"sup-config/file.c: handles '{opt}' but sample.config "
|
|
f"annotates it [{scope}] (expected '... user ...')"
|
|
)
|
|
|
|
# (f) Every [...user...] option in sample.config must also be [...vhost...]
|
|
for opt, scope in sorted(sample_scopes.items()):
|
|
if scope and "user" in scope and "vhost" not in scope:
|
|
errors.append(
|
|
f"sample.config: '{opt}' is annotated [{scope}] but "
|
|
f"user-overridable options must also be settable per-vhost "
|
|
f"(expected '[scope: vhost user]')"
|
|
)
|
|
|
|
# (g) vpn.h struct field annotations
|
|
missing_annot = check_vpn_h_annotations(vpn_h_text)
|
|
if missing_annot:
|
|
errors.append(
|
|
"src/vpn.h: the following fields are missing [scope:] annotations:"
|
|
)
|
|
errors.extend(missing_annot)
|
|
|
|
# (h) Every [scope: vhost (non-reloadable)] field in static_cfg_st must be
|
|
# referenced in vhost_inherit_static_config() or listed as an exception.
|
|
#
|
|
# Exceptions — fields that legitimately cannot use "inherit if not set":
|
|
# sup_config_type: cfg_alloc_vhost pre-sets it to SUP_CONFIG_FILE (non-zero),
|
|
# so there is no reliable zero-sentinel to detect "not explicitly set".
|
|
STATIC_VHOST_INHERIT_EXCEPTIONS = {
|
|
"sup_config_type",
|
|
}
|
|
|
|
inherit_fields = extract_static_inherit_fields(config_c_text)
|
|
vhost_nonreload_fields = extract_static_vhost_fields(vpn_h_text)
|
|
|
|
for field in sorted(vhost_nonreload_fields):
|
|
if field in STATIC_VHOST_INHERIT_EXCEPTIONS:
|
|
continue
|
|
if field not in inherit_fields:
|
|
errors.append(
|
|
f"vpn.h: static_cfg_st field '{field}' is "
|
|
f"[scope: vhost (non-reloadable)] but is not referenced in "
|
|
f"vhost_inherit_static_config() — add inheritance or add to "
|
|
f"STATIC_VHOST_INHERIT_EXCEPTIONS"
|
|
)
|
|
|
|
# Report
|
|
if errors:
|
|
print("config-scope-check: FAILED")
|
|
for e in errors:
|
|
print(f" ERROR: {e}")
|
|
return 1
|
|
|
|
print(f"config-scope-check: OK ({len(sample_scopes)} options checked)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|