> For the complete documentation index, see [llms.txt](https://axiomemu.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://axiomemu.gitbook.io/docs/joinserver/joinserver-ini.md).

# JoinServer.ini

Database connection, ports, authentication mode, and license for JoinServer

Primary configuration file for JoinServer. All keys live under a single `[JoinServerInfo]` section.

## File Location

```
JoinServer/JoinServer.ini
```

Loaded once at startup. Changes require a JoinServer restart.

## Dependencies

* `JoinServer/AllowableIpList.txt` - IP whitelist for GameServer connections. See [AllowableIpList.txt](/docs/joinserver/allowableiplist.md). Loaded only after the database, TCP listener, and UDP heartbeat target are all up.
* ConnectServer must be reachable on the configured UDP port. See [ConnectServer.ini](/docs/connectserver/connectserver-ini.md) for the matching `ConnectServerPortUDP`.
* A Windows ODBC Data Source pointing at the same SQL Server instance DataServer uses. See [DataServer.ini](/docs/dataserver/dataserver-ini.md).

## Configuration

All keys belong to section `[JoinServerInfo]`.

| Key                      | Type                  | Default     | Effect                                                                                                                                                                                                          |
| ------------------------ | --------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JoinServerODBC`         | string (31 chars max) | *(empty)*   | ODBC DSN name for SQL Server. Must exist in Windows ODBC Data Sources on the JoinServer host. Required.                                                                                                         |
| `JoinServerUSER`         | string (31 chars max) | *(empty)*   | SQL login user. Leave empty to use Windows Authentication if the DSN is configured for it.                                                                                                                      |
| `JoinServerPASS`         | string (31 chars max) | *(empty)*   | SQL login password. Leave empty when using Windows Authentication.                                                                                                                                              |
| `JoinServerPort`         | int (0 - 65535)       | `55970`     | TCP port GameServers connect to. Must match each GameServer's `JoinServerPort` setting.                                                                                                                         |
| `ConnectServerAddress`   | string (15 chars max) | `127.0.0.1` | IPv4 address or hostname of ConnectServer for the UDP heartbeat. Resolved once at startup.                                                                                                                      |
| `ConnectServerPort`      | int (0 - 65535)       | `55557`     | UDP port on ConnectServer that receives the heartbeat. Must match ConnectServer's `ConnectServerPortUDP`.                                                                                                       |
| `CaseSensitive`          | 0 or 1                | `0`         | Casing of account names in the in-memory online-account map. See [Case Sensitivity](#case-sensitivity).                                                                                                         |
| `PasswordHashMode`       | 0, 1 or 2             | `2`         | Password storage scheme. `0` = plaintext (legacy), `1` = WebZen MD5 (legacy), `2` = PBKDF2-HMAC-SHA256. See [Password Storage](#password-storage) and [Password Hashing](/docs/joinserver/password-hashing.md). |
| `LegacyPasswordMode`     | -1, 0 or 1            | `0`         | Only used when `PasswordHashMode = 2`. How to read accounts that have not been re-hashed yet: `0` = plaintext, `1` = WebZen MD5, `-1` = reject.                                                                 |
| `PasswordHashIterations` | int (>= 1000)         | `15000`     | PBKDF2 iteration count for newly written hashes. Raising it re-hashes each account on its next login.                                                                                                           |
| `LoginFailAccountMax`    | int (>= 0)            | `5`         | Failed logins on one account name inside `LoginFailWindowSeconds` before that account is blocked. `0` = off. See [Failed-Login Limits](#failed-login-limits).                                                   |
| `LoginFailIpMax`         | int (>= 0)            | `20`        | Failed logins from one client IP inside `LoginFailWindowSeconds` before that IP is blocked. `0` = off.                                                                                                          |
| `LoginFailWindowSeconds` | int (1 - 86400)       | `300`       | Time window for counting failed logins.                                                                                                                                                                         |
| `LoginFailBlockSeconds`  | int (1 - 86400)       | `300`       | How long a blocked account or IP is refused.                                                                                                                                                                    |
| `LicenseKey`             | hex string (64 chars) | *(empty)*   | License token tied to this JoinServer instance. Required; an empty or invalid key prevents startup.                                                                                                             |
| `ControlPort`            | int (0 - 65535)       | `55992`     | TCP port for the Axiom Server Manager control channel (status, logs, commands, graceful shutdown). `0` = off.                                                                                                   |
| `ControlKey`             | string (63 chars max) | *(empty)*   | Shared password for the control channel. Must match this instance's `ControlKey` in the Server Manager. Empty = no password.                                                                                    |
| `ControlBind`            | IPv4 address          | `127.0.0.1` | Address the control channel listens on. `127.0.0.1` = only this machine can connect. Not in the shipped file; add the line to change it.                                                                        |
| `ControlInstance`        | string                | *(empty)*   | Label shown in the Server Manager to tell instances apart. Not in the shipped file; add the line to set it.                                                                                                     |

{% hint style="warning" %}
The shipped `JoinServer.ini` comment says the control channel "listens on 127.0.0.1 only". That is just the default: `ControlBind` can change it. Set a `ControlKey` **before** you bind to a LAN or public address - with an empty key, anyone who can reach the port can control the server.
{% endhint %}

## Case Sensitivity

`CaseSensitive` controls how the account string is used as a key in the in-memory map that tracks currently-connected accounts (used for duplicate-login detection, map-server moves, and disconnect bookkeeping).

| Value | Behavior                                                                                                     |
| ----- | ------------------------------------------------------------------------------------------------------------ |
| `0`   | Account string is lowercased before it is used as a map key. `Admin` and `admin` collide in the online set.  |
| `1`   | Account string is used verbatim as the map key. `Admin` and `admin` are treated as separate online sessions. |

This setting does NOT rewrite the account name sent to the database. Account lookup casing in SQL Server is governed by:

* For `PasswordHashMode = 0`: the password-check query forces `COLLATE Latin1_General_BIN` on the account ID, so the DB lookup is binary-exact regardless of `CaseSensitive` or the database's default collation.
* For `PasswordHashMode = 1`: no explicit collation is forced; the database's default collation decides whether `Admin` and `admin` match on lookup.
* For `PasswordHashMode = 2`: `CaseSensitive` decides. `1` forces `COLLATE Latin1_General_BIN` on the lookup, `0` leaves it to the database collation.

{% hint style="warning" %}
Mixing `CaseSensitive` values across JoinServer restarts can leave a user double-online in the map (one entry per casing). Pick a value and keep it stable.
{% endhint %}

## Password Storage

`PasswordHashMode` selects how `MEMB_INFO.memb__pwd` is stored and compared.

| Value | Storage format in `MEMB_INFO.memb__pwd`                                       | Login comparison                                                                                                                                                                                         |
| ----- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0`   | Plain text, up to 10 chars                                                    | Binary-exact `strcmp` of submitted password against stored value. Account row is selected with `COLLATE Latin1_General_BIN`, so both the account ID and the password are case-sensitive.                 |
| `1`   | 16-byte WebZen MD5 digest stored as binary                                    | Submitted password is hashed with an account-derived init vector and compared to the stored 16 bytes. Account row is selected without a forced collation, so account-ID casing follows the DB collation. |
| `2`   | `$pbkdf2-sha256$i=<n>$<salt>$<digest>` — 93 ASCII chars, needs `varchar(120)` | PBKDF2-HMAC-SHA256 with a per-account random salt, compared in constant time. Rows still in the old format are read using `LegacyPasswordMode` and rewritten as PBKDF2 on the first successful login.    |

{% hint style="info" %}
Mode `2` is the only scheme a website or CMS can reproduce from any language using its standard library. Full setup instructions, including the required SQL migration, are on [Password Hashing](/docs/joinserver/password-hashing.md).
{% endhint %}

{% hint style="danger" %}
Switching between modes `0` and `1` against a populated `MEMB_INFO` table locks out every existing account. Moving to mode `2` does not: set `LegacyPasswordMode` to whichever scheme the table currently uses and every account migrates itself as it logs in.
{% endhint %}

{% hint style="warning" %}
With `PasswordHashMode = 0`, password and account-ID comparison at login are both case-sensitive at the database layer and are unaffected by `CaseSensitive` or the database collation.
{% endhint %}

## Failed-Login Limits

JoinServer counts failed logins (wrong password or unknown account) per account name and per client IP.

* When a count reaches its max inside `LoginFailWindowSeconds`, every login for that account or IP is refused for `LoginFailBlockSeconds`, even with the right password. The player sees "Connection closed due to 3 failed attempts." and can retry after the block ends.
* A refused login is answered before the password is hashed, so a flood of guesses cannot keep JoinServer busy.
* A successful login clears the account count. The IP count is not cleared by a success.
* Unknown account names and wrong passwords get the same answer ("Password is incorrect") and take the same time, so players cannot test which account names exist.
* Counts live in memory only and reset when JoinServer restarts.
* A block is logged once as `[LoginLimit] account (...)` or `[LoginLimit] ip (...)`.

{% hint style="warning" %}
Anyone who knows an account name can block it for `LoginFailBlockSeconds` by sending wrong passwords. Players behind one shared IP (internet cafe, carrier NAT) share the IP count; raise `LoginFailIpMax` if they get blocked.
{% endhint %}

## Important Behavior

* If `JoinServer.ini` is missing or unreadable, the Windows INI parser silently returns the defaults shown above. `LicenseKey` ends up empty, activation fails, and the process exits at the license-activation step before any socket opens.
* License activation happens before the database connection, before any TCP listener, and before the UDP heartbeat. If the license service is unreachable at startup there is no offline grace for activation and the process exits.
* After successful activation, periodic heartbeats can tolerate transient outages within a runtime grace window; repeated grace entries in a short span force shutdown. This is license-service behavior, not an INI-tunable.
* If the database connection fails, no TCP listener is ever opened and no UDP heartbeat is started.
* If the TCP listener fails to bind `JoinServerPort`, the database connection is closed and no UDP heartbeat is started.
* If the UDP target in `ConnectServerAddress` / `ConnectServerPort` cannot be resolved or the UDP socket cannot be created, the TCP listener is shut down and the database connection is closed. The IP whitelist is not loaded.
* `AllowableIpList.txt` is loaded only after all three of the above succeed. A missing or empty whitelist blocks GameServer connections; see [AllowableIpList.txt](/docs/joinserver/allowableiplist.md).
* `GetPrivateProfileString` / `GetPrivateProfileInt` silently truncate values that exceed the internal buffer sizes. Keep `JoinServerODBC`, `JoinServerUSER`, and `JoinServerPASS` under 31 characters, and `ConnectServerAddress` under 15 characters. Quotes and trailing whitespace are preserved literally.

## Examples

{% tabs %}
{% tab title="Minimal local setup" %}
{% code title="JoinServer.ini" %}

```ini
[JoinServerInfo]
JoinServerODBC = MuOnline
JoinServerPort = 55970
ConnectServerAddress = 127.0.0.1
ConnectServerPort = 55557
CaseSensitive = 0
PasswordHashMode = 2
LegacyPasswordMode = 0
PasswordHashIterations = 15000
LicenseKey = YOUR-KEY-HERE
```

{% endcode %}
{% endtab %}

{% tab title="Migrating a legacy WebZen-MD5 database" %}
{% code title="JoinServer.ini" %}

```ini
[JoinServerInfo]
JoinServerODBC = MuOnline
JoinServerUSER = mu_app
JoinServerPASS = <strong-password>
JoinServerPort = 55970
ConnectServerAddress = 10.0.0.5
ConnectServerPort = 55557
CaseSensitive = 0
PasswordHashMode = 2
LegacyPasswordMode = 1
PasswordHashIterations = 15000
LicenseKey = YOUR-KEY-HERE
```

{% endcode %}
{% endtab %}
{% endtabs %}

## Common Issues

* **Startup stops at "Loading files..."** - `LicenseKey` is empty, invalid, or the license service is unreachable.
* **"Could not connect to database"** - DSN name typo, wrong credentials, or the DSN targets the wrong SQL Server instance. No TCP port is opened when this fails.
* **Every login fails with "Password is incorrect"** - Unknown accounts get the same message as wrong passwords. With `PasswordHashMode = 0`, the account row must match byte-for-byte because of the forced binary collation. Verify the exact casing stored in `MEMB_INFO.memb___id`.
* **Log shows "could not store upgraded hash ... is memb\_\_pwd still varchar(10)?"** - The widening migration was not run. Logins still work, but every account re-verifies against the legacy value forever. Run `server\migrations\v1.0.7_widen_memb_pwd_for_pbkdf2.sql`.
* **Accounts created on the website cannot log in** - The website is writing a password format the JoinServer is not configured to read. Match `PASSWORD_HASH_MODE` to `PasswordHashMode`; see [Password Hashing](/docs/joinserver/password-hashing.md).
* **GameServer cannot reach JoinServer** - `JoinServerPort` changed but GameServer config was not updated, or a firewall blocks the port, or the GameServer's address is not in `AllowableIpList.txt`.
* **Player sees "Connection closed due to 3 failed attempts." with the right password** - The account or IP hit a failed-login limit. Wait `LoginFailBlockSeconds` or restart JoinServer. See [Failed-Login Limits](#failed-login-limits).
* **Server Manager cannot connect to JoinServer** - `ControlPort` is `0`, or `ControlKey` differs from the one set in the Server Manager.
* **ConnectServer shows JoinServer as offline even after restart** - `ConnectServerPort` does not match ConnectServer's `ConnectServerPortUDP`, `ConnectServerAddress` is wrong, or UDP is blocked between hosts. If the UDP target cannot be set up at startup, JoinServer tears the TCP listener back down.
