Skip to content

feat(serverd): interactive --setup wizard (writes openads.ini, optional service)#89

Merged
Admnwk merged 2 commits into
FiveTechSoft:mainfrom
Admnwk:feat/serverd-setup-wizard
Jun 26, 2026
Merged

feat(serverd): interactive --setup wizard (writes openads.ini, optional service)#89
Admnwk merged 2 commits into
FiveTechSoft:mainfrom
Admnwk:feat/serverd-setup-wizard

Conversation

@Admnwk

@Admnwk Admnwk commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What & why

Gives 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.

Stacked on #88 (the --config config-file support). The first commit here is #88; review/merge that first and this rebases to just the wizard commit.

Cross-platform

The prompts are plain stdin/stdout and run identically on Windows, Linux and macOS. Only "start at boot" branches per OS:

  • Windows: sets want_service; main() registers the Windows Service through the existing SCM path (binPath = --config <ini>).
  • Linux: writes a filled-in systemd unit (mirrors the tools/scripts/ template hardening) and prints the systemctl enable --now commands.
  • macOS: writes a launchd plist and prints the launchctl bootstrap commands.

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: --setup dispatch + current_exe_path() (absolute path embedded in generated units) + help text.

Validation

  • Piped answers produce the expected openads.ini; --config <that ini> then binds the chosen port and starts Studio — full wizard→file→server loop verified, no phantom.
  • Build -Werror clean (MSVC x64); unit suite unaffected (the wizard/main.cpp are not in the test target) — 795/795.

Coordination

Touches tools/serverd/ only. For @FiveTechSoft / Antonio to review and merge after #88.

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tools/serverd/main.cpp
Comment on lines +135 to +139
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--config" && i + 1 < argc) {
config_path = argv[++i];
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
        }
    }

Comment on lines +51 to +56
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
}

Comment on lines +179 to +182
if (!is_digits(port) || std::stol(port) > 65535) {
std::printf(" ! port must be a number 0..65535\n");
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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;
}

Comment on lines +201 to +202
if (is_digits(http_port) && std::stol(http_port) <= 65535) break;
std::printf(" ! port must be a number 0..65535\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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");

Comment on lines +88 to +159
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Several issues exist in the POSIX service generation:

  1. systemd strictly requires absolute paths for ReadWritePaths and WorkingDirectory. If the user uses a relative path (like the default .), the service will fail to load.
  2. If ini_path is relative, the service will fail to find it when changing directory to WorkingDirectory.
  3. macOS plist files will fail to load if paths contain XML special characters (like &).
  4. 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 += "&amp;";
            else if (c == '<')  r += "&lt;";
            else if (c == '>')  r += "&gt;";
            else if (c == '\"') r += "&quot;";
            else if (c == '\'') r += "&apos;";
            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
}

Comment on lines +109 to +110
out.http_users.emplace_back(val.substr(0, colon),
val.substr(colon + 1));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Comment on lines +205 to +211
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;
}

Comment on lines +235 to +241
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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>
@Admnwk Admnwk force-pushed the feat/serverd-setup-wizard branch from b3b842b to d7638fb Compare June 26, 2026 04:32
@Admnwk Admnwk merged commit cd957a6 into FiveTechSoft:main Jun 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant