feat(serverd): interactive --setup wizard (writes openads.ini, optional service)#89
Conversation
Ex-Advantage users expect to keep server settings in a config file
(ADS shipped ads.cfg) rather than a long command line baked into a
service binPath. Add a dependency-free INI parser and a --config
<path> flag.
Recognised keys map 1:1 to the existing CLI options: host, port,
backlog, http_port, data, http_user (repeatable user:password).
Precedence is defaults < config file < command line, so an explicit
flag always wins over the file. Both the interactive path and the
Windows service path resolve args through the same loader.
- tools/serverd/config_ini.{h,cpp}: parse_ini / load_ini_file. Pure
C++17 (std::fstream/istringstream), works identically on
Windows/Linux/macOS. Tolerates CRLF, #/; comments and an optional
[server] header; rejects unknown keys and out-of-range ports with a
line-numbered message.
- tools/serverd/main.cpp: --config plumbing (apply_ini +
load_config_and_args), help text.
- tests/unit/serverd_config_ini_test.cpp: 11 cases / 42 assertions
covering every key, the has_* flags, aliases, comments, CRLF and the
error paths.
- openads.ini.sample: documented template.
Suite 795/795, 0 regression. Smoke: --config drives the bind and a
later --port overrides the file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a minimal INI configuration parser and an interactive first-run setup wizard for the OpenADS server daemon, allowing settings to be loaded from an openads.ini file and generating system service configurations for Windows, Linux, and macOS. The review feedback highlights several critical improvement opportunities: robustly parsing command-line arguments to avoid misparsing, validating port input lengths to prevent std::stol crashes, ensuring absolute paths and XML-escaping are used in POSIX service generation, trimming whitespace around http_user credentials, rejecting colons in usernames, and verifying file write success after closing the stream.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for (int i = 1; i < argc; ++i) { | ||
| if (std::string(argv[i]) == "--config" && i + 1 < argc) { | ||
| config_path = argv[++i]; | ||
| } | ||
| } |
There was a problem hiding this comment.
The first-pass parser in load_config_and_args does not skip arguments of other known flags. If a flag's value happens to be --config (or if --config is followed by another flag because its value was omitted), it can misparse the command line and fail to start. Safely skipping arguments of other known flags in the first pass prevents this.
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--config" && i + 1 < argc) {
config_path = argv[++i];
} else if ((a == "--host" || a == "--port" || a == "--backlog" ||
a == "--http-port" || a == "--data" || a == "--http-user") &&
i + 1 < argc) {
++i;
}
}| bool is_digits(const std::string& s) { | ||
| if (s.empty()) return false; | ||
| for (char c : s) | ||
| if (c < '0' || c > '9') return false; | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Using std::stol on user-supplied port strings without length validation can cause std::out_of_range exceptions and crash the setup wizard if the user inputs an extremely long string of digits. Replacing is_digits with a safe is_valid_port helper that validates the port range and length incrementally prevents overflow/crashes.
bool is_valid_port(const std::string& s) {
if (s.empty()) return false;
unsigned long val = 0;
for (char c : s) {
if (c < '0' || c > '9') return false;
val = val * 10 + static_cast<unsigned long>(c - '0');
if (val > 65535) return false;
}
return true;
}| if (!is_digits(port) || std::stol(port) > 65535) { | ||
| std::printf(" ! port must be a number 0..65535\n"); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Use the safer is_valid_port helper to validate the port range and length incrementally, preventing potential crashes from std::stol on extremely long inputs.
| if (!is_digits(port) || std::stol(port) > 65535) { | |
| std::printf(" ! port must be a number 0..65535\n"); | |
| continue; | |
| } | |
| if (!is_valid_port(port)) { | |
| std::printf(" ! port must be a number 0..65535\n"); | |
| continue; | |
| } |
| if (is_digits(http_port) && std::stol(http_port) <= 65535) break; | ||
| std::printf(" ! port must be a number 0..65535\n"); |
There was a problem hiding this comment.
Use the safer is_valid_port helper to validate the HTTP port range and length incrementally, preventing potential crashes from std::stol on extremely long inputs.
| if (is_digits(http_port) && std::stol(http_port) <= 65535) break; | |
| std::printf(" ! port must be a number 0..65535\n"); | |
| if (is_valid_port(http_port)) break; | |
| std::printf(" ! port must be a number 0..65535\n"); |
| void write_posix_service(const std::string& exe_path, | ||
| const std::string& ini_path, | ||
| const std::string& data_dir) { | ||
| #if defined(__APPLE__) | ||
| const std::string unit = "com.openads.serverd.plist"; | ||
| std::ofstream f(unit, std::ios::binary); | ||
| if (!f) { | ||
| std::printf(" (could not write %s — skipping)\n", unit.c_str()); | ||
| return; | ||
| } | ||
| f << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" | ||
| << "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n" | ||
| << " \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" | ||
| << "<plist version=\"1.0\">\n<dict>\n" | ||
| << " <key>Label</key><string>com.openads.serverd</string>\n" | ||
| << " <key>ProgramArguments</key>\n <array>\n" | ||
| << " <string>" << exe_path << "</string>\n" | ||
| << " <string>--config</string><string>" << ini_path | ||
| << "</string>\n </array>\n" | ||
| << " <key>WorkingDirectory</key><string>" << data_dir | ||
| << "</string>\n" | ||
| << " <key>RunAtLoad</key><true/>\n" | ||
| << " <key>KeepAlive</key><dict><key>Crashed</key><true/></dict>\n" | ||
| << " <key>StandardOutPath</key>" | ||
| "<string>/var/log/openads-serverd.out.log</string>\n" | ||
| << " <key>StandardErrorPath</key>" | ||
| "<string>/var/log/openads-serverd.err.log</string>\n" | ||
| << "</dict>\n</plist>\n"; | ||
| f.close(); | ||
| std::printf( | ||
| "\nWrote %s. To start at boot (needs sudo):\n" | ||
| " sudo cp %s /Library/LaunchDaemons/\n" | ||
| " sudo launchctl bootstrap system " | ||
| "/Library/LaunchDaemons/%s\n" | ||
| " sudo launchctl kickstart -kp system/com.openads.serverd\n", | ||
| unit.c_str(), unit.c_str(), unit.c_str()); | ||
| #else | ||
| const std::string unit = "openads-serverd.service"; | ||
| std::ofstream f(unit, std::ios::binary); | ||
| if (!f) { | ||
| std::printf(" (could not write %s — skipping)\n", unit.c_str()); | ||
| return; | ||
| } | ||
| f << "[Unit]\n" | ||
| << "Description=OpenADS Database Server\n" | ||
| << "Documentation=https://github.com/FiveTechSoft/OpenADS\n" | ||
| << "After=network-online.target\n" | ||
| << "Wants=network-online.target\n\n" | ||
| << "[Service]\n" | ||
| << "Type=simple\n" | ||
| << "ExecStart=" << exe_path << " --config " << ini_path << "\n" | ||
| << "WorkingDirectory=" << data_dir << "\n" | ||
| << "Restart=on-failure\n" | ||
| << "RestartSec=5s\n" | ||
| << "NoNewPrivileges=true\n" | ||
| << "ProtectSystem=strict\n" | ||
| << "ProtectHome=true\n" | ||
| << "PrivateTmp=true\n" | ||
| << "ReadWritePaths=" << data_dir << "\n" | ||
| << "RestrictAddressFamilies=AF_INET AF_INET6\n" | ||
| << "LimitNOFILE=65535\n\n" | ||
| << "[Install]\n" | ||
| << "WantedBy=multi-user.target\n"; | ||
| f.close(); | ||
| std::printf( | ||
| "\nWrote %s. To start at boot (needs sudo):\n" | ||
| " sudo cp %s /etc/systemd/system/\n" | ||
| " sudo systemctl daemon-reload\n" | ||
| " sudo systemctl enable --now openads-serverd\n", | ||
| unit.c_str(), unit.c_str()); | ||
| #endif | ||
| } |
There was a problem hiding this comment.
Several issues exist in the POSIX service generation:
- systemd strictly requires absolute paths for
ReadWritePathsandWorkingDirectory. If the user uses a relative path (like the default.), the service will fail to load. - If
ini_pathis relative, the service will fail to find it when changing directory toWorkingDirectory. - macOS plist files will fail to load if paths contain XML special characters (like
&). - Paths with spaces are not quoted in systemd unit files.
Resolving paths to absolute paths using realpath and XML-escaping plist values solves these issues.
void write_posix_service(const std::string& exe_path,
const std::string& ini_path,
const std::string& data_dir) {
char buf[4096];
std::string abs_ini = ini_path;
if (realpath(ini_path.c_str(), buf)) abs_ini = buf;
std::string abs_data = data_dir;
if (realpath(data_dir.c_str(), buf)) abs_data = buf;
auto xml_escape = [](const std::string& s) {
std::string r;
for (char c : s) {
if (c == '&') r += "&";
else if (c == '<') r += "<";
else if (c == '>') r += ">";
else if (c == '\"') r += """;
else if (c == '\'') r += "'";
else r += c;
}
return r;
};
#if defined(__APPLE__)
const std::string unit = "com.openads.serverd.plist";
std::ofstream f(unit, std::ios::binary);
if (!f) {
std::printf(" (could not write %s — skipping)\n", unit.c_str());
return;
}
f << "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
<< "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n"
<< " \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
<< "<plist version=\"1.0\">\n<dict>\n"
<< " <key>Label</key><string>com.openads.serverd</string>\n"
<< " <key>ProgramArguments</key>\n <array>\n"
<< " <string>" << xml_escape(exe_path) << "</string>\n"
<< " <string>--config</string><string>" << xml_escape(abs_ini)
<< "</string>\n </array>\n"
<< " <key>WorkingDirectory</key><string>" << xml_escape(abs_data)
<< "</string>\n"
<< " <key>RunAtLoad</key><true/>\n"
<< " <key>KeepAlive</key><dict><key>Crashed</key><true/></dict>\n"
<< " <key>StandardOutPath</key>"
"<string>/var/log/openads-serverd.out.log</string>\n"
<< " <key>StandardErrorPath</key>"
"<string>/var/log/openads-serverd.err.log</string>\n"
<< "</dict>\n</plist>\n";
f.close();
std::printf(
"\nWrote %s. To start at boot (needs sudo):\n"
" sudo cp %s /Library/LaunchDaemons/\n"
" sudo launchctl bootstrap system "
"/Library/LaunchDaemons/%s\n"
" sudo launchctl kickstart -kp system/com.openads.serverd\n",
unit.c_str(), unit.c_str(), unit.c_str());
#else
const std::string unit = "openads-serverd.service";
std::ofstream f(unit, std::ios::binary);
if (!f) {
std::printf(" (could not write %s — skipping)\n", unit.c_str());
return;
}
f << "[Unit]\n"
<< "Description=OpenADS Database Server\n"
<< "Documentation=https://github.com/FiveTechSoft/OpenADS\n"
<< "After=network-online.target\n"
<< "Wants=network-online.target\n\n"
<< "[Service]\n"
<< "Type=simple\n"
<< "ExecStart=\"" << exe_path << "\" --config \"" << abs_ini << "\"\n"
<< "WorkingDirectory=\"" << abs_data << "\"\n"
<< "Restart=on-failure\n"
<< "RestartSec=5s\n"
<< "NoNewPrivileges=true\n"
<< "ProtectSystem=strict\n"
<< "ProtectHome=true\n"
<< "PrivateTmp=true\n"
<< "ReadWritePaths=\"" << abs_data << "\"\n"
<< "RestrictAddressFamilies=AF_INET AF_INET6\n"
<< "LimitNOFILE=65535\n\n"
<< "[Install]\n"
<< "WantedBy=multi-user.target\n";
f.close();
std::printf(
"\nWrote %s. To start at boot (needs sudo):\n"
" sudo cp %s /etc/systemd/system/\n"
" sudo systemctl daemon-reload\n"
" sudo systemctl enable --now openads-serverd\n",
unit.c_str(), unit.c_str());
#endif
}| out.http_users.emplace_back(val.substr(0, colon), | ||
| val.substr(colon + 1)); |
There was a problem hiding this comment.
When parsing http_user, any spaces around the colon are currently preserved (e.g., admin : secret results in a username of admin and a password of secret). Trimming both the username and password after splitting makes the parser much more robust against user formatting variations.
| out.http_users.emplace_back(val.substr(0, colon), | |
| val.substr(colon + 1)); | |
| std::string user = trim(val.substr(0, colon)); | |
| std::string pass = trim(val.substr(colon + 1)); | |
| out.http_users.emplace_back(user, pass); |
| if (!ask(" Studio admin user (blank = open console)", "", user)) | ||
| return false; | ||
| if (!user.empty()) { | ||
| std::string pass; | ||
| if (!ask(" Studio admin password", "", pass)) return false; | ||
| http_user = user + ":" + pass; | ||
| } |
There was a problem hiding this comment.
If the user inputs a colon : in the username during setup, it will break the http_user = user:password parsing format. Adding a check to reject colons in the username prevents this.
| if (!ask(" Studio admin user (blank = open console)", "", user)) | |
| return false; | |
| if (!user.empty()) { | |
| std::string pass; | |
| if (!ask(" Studio admin password", "", pass)) return false; | |
| http_user = user + ":" + pass; | |
| } | |
| std::string user; | |
| for (;;) { | |
| if (!ask(" Studio admin user (blank = open console)", "", user)) | |
| return false; | |
| if (user.find(':') == std::string::npos) break; | |
| std::printf(" ! username cannot contain a colon (:)\n"); | |
| } | |
| if (!user.empty()) { | |
| std::string pass; | |
| if (!ask(" Studio admin password", "", pass)) return false; | |
| http_user = user + ":" + pass; | |
| } |
| std::ofstream f(ini_path, std::ios::binary); | ||
| if (!f) { | ||
| std::printf("error: cannot write %s\n", ini_path.c_str()); | ||
| return false; | ||
| } | ||
| write_ini(f, host, port, "16", data, studio, http_port, http_user); | ||
| f.close(); |
There was a problem hiding this comment.
The wizard writes the config file but does not check if the write or close operation succeeded (e.g., if the disk is full). Checking !f after closing the file stream ensures any write errors are caught.
| std::ofstream f(ini_path, std::ios::binary); | |
| if (!f) { | |
| std::printf("error: cannot write %s\n", ini_path.c_str()); | |
| return false; | |
| } | |
| write_ini(f, host, port, "16", data, studio, http_port, http_user); | |
| f.close(); | |
| std::ofstream f(ini_path, std::ios::binary); | |
| if (!f) { | |
| std::printf("error: cannot write %s\n", ini_path.c_str()); | |
| return false; | |
| } | |
| write_ini(f, host, port, "16", data, studio, http_port, http_user); | |
| f.close(); | |
| if (!f) { | |
| std::printf("error: failed to write config to %s\n", ini_path.c_str()); | |
| return false; | |
| } |
…al service)
Give ex-Advantage users the guided first-run their old installer had.
`openads_serverd --setup` asks the same questions ADS did — bind
address, wire port (warns on the 6262 ADS clash), data directory,
whether to enable the Studio web console (the ARC replacement) and
whether to start automatically — then writes an openads.ini the daemon
reads via --config (PR adds onto the config-file support).
Cross-platform by construction: the prompts are plain stdin/stdout and
run identically on Windows, Linux and macOS. Only "start at boot"
branches per OS:
- Windows: the wizard sets want_service and main() registers the
Windows Service via the existing SCM path (binPath = --config <ini>).
- Linux: writes a filled-in systemd unit (mirrors tools/scripts/
template hardening) and prints the enable commands.
- macOS: writes a launchd plist and prints the bootstrap commands.
Code page is intentionally NOT asked: the engine has no selectable
server code page yet, and presenting a dead control violates the
no-phantom-stub rule. It slots in as one more prompt when that lands.
- tools/serverd/setup_wizard.{h,cpp}: the wizard + POSIX unit writers.
- tools/serverd/main.cpp: --setup dispatch + current_exe_path() helper
(absolute path embedded in generated units), help text.
Verified: piped answers produce the expected openads.ini, and
`--config <that ini>` then binds the chosen port and starts Studio —
full wizard->file->server loop, no phantom. Build -Werror clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b3b842b to
d7638fb
Compare
What & why
Gives ex-Advantage users the guided first-run their old installer had.
openads_serverd --setupasks the same questions ADS did — bind address, wire port (warns on the 6262 ADS clash), data directory, whether to enable the Studio web console (the ARC replacement), and whether to start automatically — then writes anopenads.inithe daemon reads via--config.Cross-platform
The prompts are plain stdin/stdout and run identically on Windows, Linux and macOS. Only "start at boot" branches per OS:
want_service;main()registers the Windows Service through the existing SCM path (binPath =--config <ini>).tools/scripts/template hardening) and prints thesystemctl enable --nowcommands.launchctl bootstrapcommands.Deliberately no code-page prompt
The engine has no selectable server code page yet (UTF-8 / CP437 today), and presenting a control that does nothing would be a phantom. It slots in as one more prompt + one more ini key when code-page selection lands.
Design notes
tools/serverd/setup_wizard.{h,cpp}: the wizard + the POSIX unit writers.tools/serverd/main.cpp:--setupdispatch +current_exe_path()(absolute path embedded in generated units) + help text.Validation
openads.ini;--config <that ini>then binds the chosen port and starts Studio — full wizard→file→server loop verified, no phantom.-Werrorclean (MSVC x64); unit suite unaffected (the wizard/main.cppare not in the test target) — 795/795.Coordination
Touches
tools/serverd/only. For @FiveTechSoft / Antonio to review and merge after #88.