From 5e3a70f837470a83e103304fb418e7ee5470800c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Mon, 24 Nov 2025 12:53:06 -0600 Subject: [PATCH] Fix schedule management and update documentation for database-backed configs This commit addresses multiple issues with schedule management and updates documentation to reflect the transition from YAML-based to database-backed configuration system. **Documentation Updates:** - Update DEPLOYMENT.md to remove all references to YAML config files - Document that all configurations are now stored in SQLite database - Update API examples to use config IDs instead of YAML filenames - Remove configs directory from backup/restore procedures - Update volume management section to reflect database-only storage **Cron Expression Handling:** - Add comprehensive documentation for APScheduler cron format conversion - Document that from_crontab() accepts standard format (Sunday=0) and converts automatically - Add validate_cron_expression() helper method with detailed error messages - Include helpful hints for day-of-week field errors in validation - Fix all deprecated datetime.utcnow() calls, replace with datetime.now(timezone.utc) **Timezone-Aware DateTime Fixes:** - Fix "can't subtract offset-naive and offset-aware datetimes" error - Add timezone awareness to croniter.get_next() return values - Make _get_relative_time() defensive to handle both naive and aware datetimes - Ensure all datetime comparisons use timezone-aware objects **Schedule Edit UI Fixes:** - Fix JavaScript error "Cannot set properties of null (setting 'value')" - Change reference from non-existent 'config-id' to correct 'config-file' element - Add config_name field to schedule API responses for better UX - Eagerly load Schedule.config relationship using joinedload() - Fix AttributeError: use schedule.config.title instead of .name - Display config title and ID in schedule edit form **Technical Details:** - app/web/services/schedule_service.py: 6 datetime.utcnow() fixes, validation enhancements - app/web/services/scheduler_service.py: Documentation, validation, timezone fixes - app/web/templates/schedule_edit.html: JavaScript element reference fix - docs/DEPLOYMENT.md: Complete rewrite of config management sections Fixes scheduling for Sunday at midnight (cron: 0 0 * * 0) Fixes schedule edit page JavaScript errors Improves user experience with config title display --- .env.example | 6 +- app/src/scanner.py | 50 +++++-- app/web/services/schedule_service.py | 79 ++++++++-- app/web/services/scheduler_service.py | 71 ++++++++- app/web/templates/schedule_edit.html | 6 +- docs/DEPLOYMENT.md | 203 ++++++++++++-------------- docs/KNOWN_ISSUES.md | 0 7 files changed, 276 insertions(+), 139 deletions(-) create mode 100644 docs/KNOWN_ISSUES.md diff --git a/.env.example b/.env.example index c057551..3938ee6 100644 --- a/.env.example +++ b/.env.example @@ -65,8 +65,12 @@ UDP_SCAN_ENABLED=false # UDP ports to scan when enabled # Supports ranges (e.g., 100-200), lists (e.g., 53,67,68), or mixed (e.g., 53,67-69,123) + # Default: common UDP services -UDP_PORTS=53,67,68,69,123,161,500,514,1900 +UDP_PORTS=53,67,68,69,123,135,137,138,139,161,162,445,500,514,520,631,1434,1900,4500,49152 + +# NMAP Top 100 UDP Ports +# UDP_PORTS=2,3,7,9,13,17,19,20,21,22,23,37,38,42,49,53,67,68,69,80,88,111,112,113,120,123,135,136,137,138,139,158,161,162,177,192,199,207,217,363,389,402,407,427,434,443,445,464,497,500,502,512,513,514,515,517,518,520,539,559,593,623,626,631,639,643,657,664,682,683,684,685,686,687,688,689,764,767,772,773,774,775,776,780,781,782,786,789,800,814,826,829,838,902,903,944,959,965,983,989,990,996,997,998,999,1000,1001,1007,1008,1012,1013,1014,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,1038,1039,1040,1041,1042,1043,1044,1045,1046,1047,1048,1049,1050,1051,1053,1054,1055,1056,1057,1058,1059,1060,1064,1065,1066,1067,1068,1069,1070,1072,1080,1081,1087,1088,1090,1100,1101,1105,1124,1200,1214,1234,1346,1419,1433,1434,1455,1457,1484,1485,1524,1645,1646,1701,1718,1719,1761,1782,1804,1812,1813,1885,1886,1900,1901,1993,2000,2002,2048,2049,2051,2148,2160,2161,2222,2223,2343,2345,2362,2967,3052,3130,3283,3296,3343,3389,3401,3456,3457,3659,3664,3702,3703,4000,4008,4045,4444,4500,4666,4672,5000,5001,5002,5003,5010,5050,5060,5093,5351,5353,5355,5500,5555,5632,6000,6001,6002,6004,6050,6346,6347,6970,6971,7000,7938,8000,8001,8010,8181,8193,8900,9000,9001,9020,9103,9199,9200,9370,9876,9877,9950,10000,10080,11487,16086,16402,16420,16430,16433,16449,16498,16503,16545,16548,16573,16674,16680,16697,16700,16708,16711,16739,16766,16779,16786,16816,16829,16832,16838,16839,16862,16896,16912,16918,16919,16938,16939,16947,16948,16970,16972,16974,17006,17018,17077,17091,17101,17146,17184,17185,17205,17207,17219,17236,17237,17282,17302,17321,17331,17332,17338,17359,17417,17423,17424,17455,17459,17468,17487,17490,17494,17505,17533,17549,17573,17580,17585,17592,17605,17615,17616,17629,17638,17663,17673,17674,17683,17726,17754,17762,17787,17814,17823,17824,17836,17845,17888,17939,17946,17989,18004,18081,18113,18134,18156,18228,18234,18250,18255,18258,18319,18331,18360,18373,18449,18485,18543,18582,18605,18617,18666,18669,18676,18683,18807,18818,18821,18830,18832,18835,18869,18883,18888,18958,18980,18985,18987,18991,18994,18996,19017,19022,19039,19047,19075,19096,19120,19130,19140,19141,19154,19161,19165,19181,19193,19197,19222,19227,19273,19283,19294,19315,19322,19332,19374,19415,19482,19489,19500,19503,19504,19541,19600,19605,19616,19624,19625,19632,19639,19647,19650,19660,19662,19663,19682,19683,19687,19695,19707,19717,19718,19719,19722,19728,19789,19792,19933,19935,19936,19956,19995,19998,20003,20004,20019,20031,20082,20117,20120,20126,20129,20146,20154,20164,20206,20217,20249,20262,20279,20288,20309,20313,20326,20359,20360,20366,20380,20389,20409,20411,20423,20424,20425,20445,20449,20464,20465,20518,20522,20525,20540,20560,20665,20678,20679,20710,20717,20742,20752,20762,20791,20817,20842,20848,20851,20865,20872,20876,20884,20919,21000,21016,21060,21083,21104,21111,21131,21167,21186,21206,21207,21212,21247,21261,21282,21298,21303,21318,21320,21333,21344,21354,21358,21360,21364,21366,21383,21405,21454,21468,21476,21514,21524,21525,21556,21566,21568,21576,21609,21621,21625,21644,21649,21655,21663,21674,21698,21702,21710,21742,21780,21784,21800,21803,21834,21842,21847,21868,21898,21902,21923,21948,21967,22029,22043,22045,22053,22055,22105,22109,22123,22124,22341,22692,22695,22739,22799,22846,22914,22986,22996,23040,23176,23354,23531,23557,23608,23679,23781,23965,23980,24007,24279,24511,24594,24606,24644,24854,24910,25003,25157,25240,25280,25337,25375,25462,25541,25546,25709,25931,26407,26415,26720,26872,26966,27015,27195,27444,27473,27482,27707,27892,27899,28122,28369,28465,28493,28543,28547,28641,28840,28973,29078,29243,29256,29810,29823,29977,30263,30303,30365,30544,30656,30697,30704,30718,30975,31059,31073,31109,31189,31195,31335,31337,31365,31625,31681,31731,31891,32345,32385,32528,32768,32769,32770,32771,32772,32773,32774,32775,32776,32777,32778,32779,32780,32798,32815,32818,32931,33030,33249,33281,33354,33355,33459,33717,33744,33866,33872,34038,34079,34125,34358,34422,34433,34555,34570,34577,34578,34579,34580,34758,34796,34855,34861,34862,34892,35438,35702,35777,35794,36108,36206,36384,36458,36489,36669,36778,36893,36945,37144,37212,37393,37444,37602,37761,37783,37813,37843,38037,38063,38293,38412,38498,38615,39213,39217,39632,39683,39714,39723,39888,40019,40116,40441,40539,40622,40708,40711,40724,40732,40805,40847,40866,40915,41058,41081,41308,41370,41446,41524,41638,41702,41774,41896,41967,41971,42056,42172,42313,42431,42434,42508,42557,42577,42627,42639,43094,43195,43370,43514,43686,43824,43967,44101,44160,44179,44185,44190,44253,44334,44508,44923,44946,44968,45247,45380,45441,45685,45722,45818,45928,46093,46532,46836,47624,47765,47772,47808,47915,47981,48078,48189,48255,48455,48489,48761,49152,49153,49154,49155,49156,49157,49158,49159,49160,49161,49162,49163,49165,49166,49167,49168,49169,49170,49171,49172,49173,49174,49175,49176,49177,49178,49179,49180,49181,49182,49184,49185,49186,49187,49188,49189,49190,49191,49192,49193,49194,49195,49196,49197,49198,49199,49200,49201,49202,49204,49205,49207,49208,49209,49210,49211,49212,49213,49214,49215,49216,49220,49222,49226,49259,49262,49306,49350,49360,49393,49396,49503,49640,49968,50099,50164,50497,50612,50708,50919,51255,51456,51554,51586,51690,51717,51905,51972,52144,52225,52503,53006,53037,53571,53589,53838,54094,54114,54281,54321,54711,54807,54925,55043,55544,55587,56141,57172,57409,57410,57813,57843,57958,57977,58002,58075,58178,58419,58631,58640,58797,59193,59207,59765,59846,60172,60381,60423,61024,61142,61319,61322,61370,61412,61481,61550,61685,61961,62154,62287,62575,62677,62699,62958,63420,63555,64080,64481,64513,64590,64727,65024 # ================================ # Initial Password (First Run) diff --git a/app/src/scanner.py b/app/src/scanner.py index 1cbffbc..5aa21bc 100644 --- a/app/src/scanner.py +++ b/app/src/scanner.py @@ -676,29 +676,57 @@ class SneakyScanner: return services - def _is_likely_web_service(self, service: Dict) -> bool: + def _is_likely_web_service(self, service: Dict, ip: str = None) -> bool: """ - Check if a service is likely HTTP/HTTPS based on nmap detection or common web ports + Check if a service is a web server by actually making an HTTP request Args: service: Service dictionary from nmap results + ip: IP address to test (required for HTTP probe) Returns: - True if service appears to be web-related + True if service responds to HTTP/HTTPS requests """ - # Check service name + import requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + # Quick check for known web service names first web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt', 'http-alt', 'ssl/http', 'ssl/https'] service_name = service.get('service', '').lower() - if service_name in web_services: - return True - - # Check common non-standard web ports - web_ports = [80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443] + # If no IP provided, can't do HTTP probe port = service.get('port') + if not ip or not port: + # check just the service if no IP - honestly shouldn't get here, but just incase... + if service_name in web_services: + return True + return False - return port in web_ports + # Actually try to connect - this is the definitive test + # Try HTTPS first, then HTTP + for protocol in ['https', 'http']: + url = f"{protocol}://{ip}:{port}/" + try: + response = requests.get( + url, + timeout=3, + verify=False, + allow_redirects=False + ) + # Any status code means it's a web server + # (including 404, 500, etc. - still a web server) + return True + except requests.exceptions.SSLError: + # SSL error on HTTPS, try HTTP next + continue + except (requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.RequestException): + continue + + return False def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str: """ @@ -886,7 +914,7 @@ class SneakyScanner: ip_results = {} for service in services: - if not self._is_likely_web_service(service): + if not self._is_likely_web_service(service, ip): continue port = service['port'] diff --git a/app/web/services/schedule_service.py b/app/web/services/schedule_service.py index e134abe..514dd23 100644 --- a/app/web/services/schedule_service.py +++ b/app/web/services/schedule_service.py @@ -6,7 +6,7 @@ scheduled scans with cron expressions. """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from croniter import croniter @@ -71,6 +71,7 @@ class ScheduleService: next_run = self.calculate_next_run(cron_expression) if enabled else None # Create schedule record + now_utc = datetime.now(timezone.utc) schedule = Schedule( name=name, config_id=config_id, @@ -78,8 +79,8 @@ class ScheduleService: enabled=enabled, last_run=None, next_run=next_run, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow() + created_at=now_utc, + updated_at=now_utc ) self.db.add(schedule) @@ -103,7 +104,14 @@ class ScheduleService: Raises: ValueError: If schedule not found """ - schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first() + from sqlalchemy.orm import joinedload + + schedule = ( + self.db.query(Schedule) + .options(joinedload(Schedule.config)) + .filter(Schedule.id == schedule_id) + .first() + ) if not schedule: raise ValueError(f"Schedule {schedule_id} not found") @@ -138,8 +146,10 @@ class ScheduleService: 'pages': int } """ - # Build query - query = self.db.query(Schedule) + from sqlalchemy.orm import joinedload + + # Build query and eagerly load config relationship + query = self.db.query(Schedule).options(joinedload(Schedule.config)) # Apply filter if enabled_filter is not None: @@ -215,7 +225,7 @@ class ScheduleService: if hasattr(schedule, key): setattr(schedule, key, value) - schedule.updated_at = datetime.utcnow() + schedule.updated_at = datetime.now(timezone.utc) self.db.commit() self.db.refresh(schedule) @@ -298,7 +308,7 @@ class ScheduleService: schedule.last_run = last_run schedule.next_run = next_run - schedule.updated_at = datetime.utcnow() + schedule.updated_at = datetime.now(timezone.utc) self.db.commit() @@ -311,23 +321,43 @@ class ScheduleService: Validate a cron expression. Args: - cron_expr: Cron expression to validate + cron_expr: Cron expression to validate in standard crontab format + Format: minute hour day month day_of_week + Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday + (APScheduler will convert this to its internal format automatically) Returns: Tuple of (is_valid, error_message) - (True, None) if valid - (False, error_message) if invalid + + Note: + This validates using croniter which uses standard crontab format. + APScheduler's from_crontab() will handle the conversion when the + schedule is registered with the scheduler. """ try: # Try to create a croniter instance - base_time = datetime.utcnow() + # croniter uses standard crontab format (Sunday=0) + from datetime import timezone + base_time = datetime.now(timezone.utc) cron = croniter(cron_expr, base_time) # Try to get the next run time (validates the expression) cron.get_next(datetime) + # Validate basic format (5 fields) + fields = cron_expr.split() + if len(fields) != 5: + return (False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}") + return (True, None) except (ValueError, KeyError) as e: + error_msg = str(e) + # Add helpful hint for day_of_week errors + if "day" in error_msg.lower() and len(cron_expr.split()) >= 5: + hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday" + return (False, f"{error_msg}{hint}") return (False, str(e)) except Exception as e: return (False, f"Unexpected error: {str(e)}") @@ -345,17 +375,24 @@ class ScheduleService: from_time: Base time (defaults to now UTC) Returns: - Next run datetime (UTC) + Next run datetime (UTC, timezone-aware) Raises: ValueError: If cron expression is invalid """ if from_time is None: - from_time = datetime.utcnow() + from_time = datetime.now(timezone.utc) try: cron = croniter(cron_expr, from_time) - return cron.get_next(datetime) + next_run = cron.get_next(datetime) + + # croniter returns naive datetime, so we need to add timezone info + # Since we're using UTC for all calculations, add UTC timezone + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=timezone.utc) + + return next_run except Exception as e: raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}") @@ -403,10 +440,16 @@ class ScheduleService: Returns: Dictionary representation """ + # Get config title if relationship is loaded + config_name = None + if schedule.config: + config_name = schedule.config.title + return { 'id': schedule.id, 'name': schedule.name, 'config_id': schedule.config_id, + 'config_name': config_name, 'cron_expression': schedule.cron_expression, 'enabled': schedule.enabled, 'last_run': schedule.last_run.isoformat() if schedule.last_run else None, @@ -421,7 +464,7 @@ class ScheduleService: Format datetime as relative time. Args: - dt: Datetime to format (UTC) + dt: Datetime to format (UTC, can be naive or aware) Returns: Human-readable relative time (e.g., "in 2 hours", "yesterday") @@ -429,7 +472,13 @@ class ScheduleService: if dt is None: return None - now = datetime.utcnow() + # Ensure both datetimes are timezone-aware for comparison + now = datetime.now(timezone.utc) + + # If dt is naive, assume it's UTC and add timezone info + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + diff = dt - now # Future times diff --git a/app/web/services/scheduler_service.py b/app/web/services/scheduler_service.py index 28f2983..fe3fb42 100644 --- a/app/web/services/scheduler_service.py +++ b/app/web/services/scheduler_service.py @@ -149,6 +149,51 @@ class SchedulerService: except Exception as e: logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True) + @staticmethod + def validate_cron_expression(cron_expression: str) -> tuple[bool, str]: + """ + Validate a cron expression and provide helpful feedback. + + Args: + cron_expression: Cron expression to validate + + Returns: + Tuple of (is_valid: bool, message: str) + - If valid: (True, "Valid cron expression") + - If invalid: (False, "Error message with details") + + Note: + Standard crontab format: minute hour day month day_of_week + Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday (or 7=Sunday) + """ + from apscheduler.triggers.cron import CronTrigger + + try: + # Try to parse the expression + trigger = CronTrigger.from_crontab(cron_expression) + + # Validate basic format (5 fields) + fields = cron_expression.split() + if len(fields) != 5: + return False, f"Cron expression must have 5 fields (minute hour day month day_of_week), got {len(fields)}" + + return True, "Valid cron expression" + + except (ValueError, KeyError) as e: + error_msg = str(e) + + # Provide helpful hints for common errors + if "day_of_week" in error_msg.lower() or (len(cron_expression.split()) >= 5): + # Check if day_of_week field might be using APScheduler format by mistake + fields = cron_expression.split() + if len(fields) == 5: + dow_field = fields[4] + if dow_field.isdigit() and int(dow_field) >= 0: + hint = "\nNote: Use standard crontab format where 0=Sunday, 1=Monday, ..., 6=Saturday" + return False, f"Invalid cron expression: {error_msg}{hint}" + + return False, f"Invalid cron expression: {error_msg}" + def queue_scan(self, scan_id: int, config_id: int) -> str: """ Queue a scan for immediate background execution. @@ -188,6 +233,10 @@ class SchedulerService: schedule_id: Database ID of the schedule config_id: Database config ID cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily) + IMPORTANT: Use standard crontab format where: + - Day of week: 0 = Sunday, 1 = Monday, ..., 6 = Saturday + - APScheduler automatically converts to its internal format + - from_crontab() handles the conversion properly Returns: Job ID from APScheduler @@ -195,18 +244,29 @@ class SchedulerService: Raises: RuntimeError: If scheduler not initialized ValueError: If cron expression is invalid + + Note: + APScheduler internally uses Monday=0, but from_crontab() accepts + standard crontab format (Sunday=0) and converts it automatically. """ if not self.scheduler: raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.") from apscheduler.triggers.cron import CronTrigger + # Validate cron expression first to provide helpful error messages + is_valid, message = self.validate_cron_expression(cron_expression) + if not is_valid: + raise ValueError(message) + # Create cron trigger from expression using local timezone - # This allows users to specify times in their local timezone + # from_crontab() parses standard crontab format (Sunday=0) + # and converts to APScheduler's internal format (Monday=0) automatically try: trigger = CronTrigger.from_crontab(cron_expression) # timezone defaults to local system timezone except (ValueError, KeyError) as e: + # This should not happen due to validation above, but catch anyway raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}") # Add cron job @@ -294,11 +354,16 @@ class SchedulerService: # Update schedule's last_run and next_run from croniter import croniter - next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime) + now_utc = datetime.now(timezone.utc) + next_run = croniter(schedule['cron_expression'], now_utc).get_next(datetime) + + # croniter returns naive datetime, add UTC timezone + if next_run.tzinfo is None: + next_run = next_run.replace(tzinfo=timezone.utc) schedule_service.update_run_times( schedule_id=schedule_id, - last_run=datetime.utcnow(), + last_run=now_utc, next_run=next_run ) diff --git a/app/web/templates/schedule_edit.html b/app/web/templates/schedule_edit.html index ae09a90..25fc580 100644 --- a/app/web/templates/schedule_edit.html +++ b/app/web/templates/schedule_edit.html @@ -298,7 +298,11 @@ async function loadSchedule() { function populateForm(schedule) { document.getElementById('schedule-id').value = schedule.id; document.getElementById('schedule-name').value = schedule.name; - document.getElementById('config-id').value = schedule.config_id; + // Display config name and ID in the readonly config-file field + const configDisplay = schedule.config_name + ? `${schedule.config_name} (ID: ${schedule.config_id})` + : `Config ID: ${schedule.config_id}`; + document.getElementById('config-file').value = configDisplay; document.getElementById('cron-expression').value = schedule.cron_expression; document.getElementById('schedule-enabled').checked = schedule.enabled; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7216be9..1da506f 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -24,10 +24,10 @@ SneakyScanner is deployed as a Docker container running a Flask web application **Architecture:** - **Web Application**: Flask app on port 5000 with modern web UI -- **Database**: SQLite (persisted to volume) +- **Database**: SQLite (persisted to volume) - stores all configurations, scan results, and settings - **Background Jobs**: APScheduler for async scan execution - **Scanner**: masscan, nmap, sslyze, Playwright -- **Config Creator**: Web-based CIDR-to-YAML configuration builder +- **Config Management**: Database-backed configuration system managed entirely via web UI - **Scheduling**: Cron-based scheduled scans with dashboard management --- @@ -143,6 +143,13 @@ docker compose -f docker-compose-standalone.yml up SneakyScanner is configured via environment variables. The recommended approach is to use a `.env` file. + +**UDP Port Scanning** + +- UDP Port scanning is disabled by default. +- You can turn it on via the .env variable. +- By Default, UDP port scanning only scans the top 20 ports, for convenience I have included the NMAP top 100 UDP ports as well. + #### Creating Your .env File ```bash @@ -160,6 +167,7 @@ python3 -c "from cryptography.fernet import Fernet; print('SNEAKYSCANNER_ENCRYPT nano .env ``` + #### Key Configuration Options | Variable | Description | Default | Required | @@ -190,54 +198,30 @@ The application needs these directories (created automatically by Docker): ```bash # Verify directories exist -ls -la configs/ data/ output/ logs/ +ls -la data/ output/ logs/ # If missing, create them -mkdir -p configs data output logs +mkdir -p data output logs ``` ### Step 2: Configure Scan Targets -You can create scan configurations in two ways: +After starting the application, create scan configurations using the web UI: -**Option A: Using the Web UI (Recommended - Phase 4 Feature)** +**Creating Configurations via Web UI** 1. Navigate to **Configs** in the web interface 2. Click **"Create New Config"** -3. Use the CIDR-based config creator for quick setup: +3. Use the form-based config creator: - Enter site name - Enter CIDR range (e.g., `192.168.1.0/24`) - - Select expected ports from dropdowns - - Click **"Generate Config"** -4. Or use the **YAML Editor** for advanced configurations -5. Save and use immediately in scans or schedules + - Select expected TCP/UDP ports from dropdowns + - Optionally enable ping checks +4. Click **"Save Configuration"** +5. Configuration is saved to database and immediately available for scans and schedules -**Option B: Manual YAML Files** +**Note**: All configurations are stored in the database, not as files. This provides better reliability, easier backup, and seamless management through the web interface. -Create YAML configuration files manually in the `configs/` directory: - -```bash -# Example configuration -cat > configs/my-network.yaml <