OpenHFDS 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhfds-0.1.0/.gitignore +8 -0
- openhfds-0.1.0/LICENSE +21 -0
- openhfds-0.1.0/OpenHFDS/__init__.py +3 -0
- openhfds-0.1.0/OpenHFDS/cli.py +77 -0
- openhfds-0.1.0/OpenHFDS/hf_client_request.py +123 -0
- openhfds-0.1.0/OpenHFDS/hf_session.py +183 -0
- openhfds-0.1.0/OpenHFDS/logger.py +36 -0
- openhfds-0.1.0/OpenHFDS/server.py +70 -0
- openhfds-0.1.0/OpenHFDS/version.py +5 -0
- openhfds-0.1.0/PKG-INFO +131 -0
- openhfds-0.1.0/README.md +111 -0
- openhfds-0.1.0/pyproject.toml +52 -0
openhfds-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ty Porter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from OpenHFDS.hf_session import (
|
|
3
|
+
DEFAULT_TTL,
|
|
4
|
+
DEFAULT_MAX_ENTRIES,
|
|
5
|
+
CLIENT_HEARTBEAT_SECONDS,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
DEFAULT_BIND = "127.0.0.1"
|
|
9
|
+
DEFAULT_PORT = 10666
|
|
10
|
+
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _int_in_range(name: str, low: int, high: int | None = None):
|
|
14
|
+
def parse(text: str) -> int:
|
|
15
|
+
try:
|
|
16
|
+
value = int(text, 0)
|
|
17
|
+
except ValueError:
|
|
18
|
+
raise argparse.ArgumentTypeError(f"{text!r} is not a whole number")
|
|
19
|
+
|
|
20
|
+
if value < low or (high is not None and value > high):
|
|
21
|
+
bounds = f"at least {low}" if high is None else f"between {low} and {high}"
|
|
22
|
+
raise argparse.ArgumentTypeError(f"{name} must be {bounds}, got {value}")
|
|
23
|
+
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
return parse
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="openhfds",
|
|
32
|
+
description="HellFighter directory server: lists hosted game sessions for the game's server browser.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--bind",
|
|
36
|
+
default=DEFAULT_BIND,
|
|
37
|
+
metavar="ADDRESS",
|
|
38
|
+
help="address to listen on; use 0.0.0.0 to accept other machines (default: %(default)s)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--port",
|
|
42
|
+
type=_int_in_range("port", 1, 65535),
|
|
43
|
+
default=DEFAULT_PORT,
|
|
44
|
+
help="UDP port to listen on; the game itself always uses %(default)s (default: %(default)s)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--ttl",
|
|
48
|
+
type=_int_in_range("ttl", 1),
|
|
49
|
+
default=DEFAULT_TTL,
|
|
50
|
+
metavar="SECONDS",
|
|
51
|
+
help=f"drop a host after this long without a 'set'; by default the game sends one every {CLIENT_HEARTBEAT_SECONDS}s "
|
|
52
|
+
"(default: %(default)s)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--max-entries",
|
|
56
|
+
type=_int_in_range("max-entries", 1, DEFAULT_MAX_ENTRIES),
|
|
57
|
+
default=DEFAULT_MAX_ENTRIES,
|
|
58
|
+
metavar="COUNT",
|
|
59
|
+
help=f"most sessions to list; the game can't hold more than {DEFAULT_MAX_ENTRIES} (default: %(default)s)",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--spoof",
|
|
63
|
+
type=_int_in_range("spoof", 0, DEFAULT_MAX_ENTRIES),
|
|
64
|
+
default=0,
|
|
65
|
+
metavar="COUNT",
|
|
66
|
+
help="start with this many made-up sessions, to try out the game's server browser; "
|
|
67
|
+
"they can't be joined and expire after --ttl (default: %(default)s)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--log-level",
|
|
71
|
+
type=str.upper,
|
|
72
|
+
choices=LOG_LEVELS,
|
|
73
|
+
default="INFO",
|
|
74
|
+
help="logging verbosity; INFO includes every packet received and sent (default: %(default)s)",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return parser.parse_args(argv)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from OpenHFDS.logger import log_received_udp_data, log_sent_udp_data, Logger
|
|
2
|
+
from OpenHFDS.hf_session import HFSessionList, HFSession
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
HF_REQUEST_FIELD_SEPARATOR = " "
|
|
7
|
+
HF_REPLY_TERMINATOR = b"\0"
|
|
8
|
+
HF_CLIENT_FIELD_MAX = (
|
|
9
|
+
63 # client reads each %s into a 64-byte buffer with no bounds check
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HFUnknownClientRequest(RuntimeError):
|
|
14
|
+
"""The datagram isn't an OpenHFDS request at all (stray traffic, scanners), so it isn't worth a traceback."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
HFRequestHandler = Callable[[str, HFSessionList, socket.socket, tuple[str, int]], None]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_hf_udp(data: bytes, addr: tuple[str, int]) -> tuple[str, HFRequestHandler]:
|
|
21
|
+
log_received_udp_data(data, addr)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
request = data.decode("ascii")
|
|
25
|
+
except UnicodeDecodeError:
|
|
26
|
+
raise HFUnknownClientRequest(f"Unable to decode {data!r} as ASCII")
|
|
27
|
+
|
|
28
|
+
if request.startswith("info"):
|
|
29
|
+
return (request, handle_info)
|
|
30
|
+
elif request.startswith("get"):
|
|
31
|
+
return (request, handle_get)
|
|
32
|
+
elif request.startswith("set"):
|
|
33
|
+
return (request, handle_set)
|
|
34
|
+
elif request.startswith("delete me"):
|
|
35
|
+
return (request, handle_delete)
|
|
36
|
+
else:
|
|
37
|
+
raise HFUnknownClientRequest(f"Unable to handle '{request}'")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle_info(
|
|
41
|
+
_request: str, sessions: HFSessionList, sock: socket.socket, addr: tuple[str, int]
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Handles <info> requests. Sends the session count to the client."""
|
|
44
|
+
_send(sock, f"info {sessions.count} 0 0 0 0".encode("ascii"), addr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def handle_get(
|
|
48
|
+
request: str, sessions: HFSessionList, sock: socket.socket, addr: tuple[str, int]
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Handles <get STARTID STOPID> requests. Sends all available sessions in the range, one datagram each."""
|
|
51
|
+
args = request.split(HF_REQUEST_FIELD_SEPARATOR)
|
|
52
|
+
start = int(args[1])
|
|
53
|
+
stop = (int(args[2]) if len(args) > 2 else start) + 1
|
|
54
|
+
|
|
55
|
+
# IDs are always 0..count-1, so don't loop over absurd ranges from a bad or hostile request
|
|
56
|
+
start = max(start, 0)
|
|
57
|
+
stop = min(stop, sessions.count)
|
|
58
|
+
|
|
59
|
+
for ID in range(start, stop):
|
|
60
|
+
session = sessions.get_by_id(ID)
|
|
61
|
+
|
|
62
|
+
if session is not None:
|
|
63
|
+
field_values = [
|
|
64
|
+
str(getattr(session, f.name)) for f in HFSession.visible_fields(session)
|
|
65
|
+
]
|
|
66
|
+
# The ID is the session's position in the list, so it's written here and not stored on the session
|
|
67
|
+
_send(sock, f"get {ID} {' '.join(field_values)}".encode("ascii"), addr)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def handle_set(
|
|
71
|
+
request: str, sessions: HFSessionList, _sock: socket.socket, addr: tuple[str, int]
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Handles <set NAME LOCATION CONNECTION CURPLAYERS MAXPLAYERS FLAGS VERSION> requests.
|
|
75
|
+
|
|
76
|
+
If the session does not exist, it creates it. Otherwise, the existing entry is updated with the new values.
|
|
77
|
+
Either way the `last_seen` attribute of the session is ticked, so this request also functions as a heartbeat.
|
|
78
|
+
Clients should send it faster than the list's TTL setting, otherwise the session is automatically removed.
|
|
79
|
+
"""
|
|
80
|
+
name, location, connection, current, maximum, flags, version = request.split(
|
|
81
|
+
HF_REQUEST_FIELD_SEPARATOR
|
|
82
|
+
)[1:]
|
|
83
|
+
|
|
84
|
+
# Parse everything before touching the session, so a bad value can't leave an entry half-updated
|
|
85
|
+
values = {
|
|
86
|
+
"name": name[:HF_CLIENT_FIELD_MAX],
|
|
87
|
+
"location": location[:HF_CLIENT_FIELD_MAX],
|
|
88
|
+
"connection": connection[:HF_CLIENT_FIELD_MAX],
|
|
89
|
+
"current_players": int(current),
|
|
90
|
+
"max_players": int(maximum),
|
|
91
|
+
"flags": int(flags),
|
|
92
|
+
"version": version[:HF_CLIENT_FIELD_MAX],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
session = sessions.get_by_hostname(addr[0])
|
|
96
|
+
existing = session is not None
|
|
97
|
+
session = session or HFSession(hostname=addr[0])
|
|
98
|
+
|
|
99
|
+
for attr, value in values.items():
|
|
100
|
+
setattr(session, attr, value)
|
|
101
|
+
session.heartbeat()
|
|
102
|
+
|
|
103
|
+
if not existing:
|
|
104
|
+
sessions.add(session)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def handle_delete(
|
|
108
|
+
_request: str, sessions: HFSessionList, _sock: socket.socket, addr: tuple[str, int]
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Handles <delete me> requests. Fetches by hostname and deletes the session from the list."""
|
|
111
|
+
session = sessions.get_by_hostname(addr[0])
|
|
112
|
+
if session is None:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
sessions.remove(session)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _send(sock: socket.socket, data: bytes, addr: tuple[str, int]) -> None:
|
|
119
|
+
# The client expects a null-terminated string for anything sent to it,
|
|
120
|
+
# although it is not so kind to do that to its own requests.
|
|
121
|
+
data += HF_REPLY_TERMINATOR
|
|
122
|
+
log_sent_udp_data(data, addr)
|
|
123
|
+
sock.sendto(data, addr)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field, fields, Field
|
|
4
|
+
from OpenHFDS.logger import Logger
|
|
5
|
+
|
|
6
|
+
DEFAULT_TTL = 600 # seconds before a silent host is dropped; the client re-sends "set" every 180 s
|
|
7
|
+
DEFAULT_MAX_ENTRIES = 0x1000 # client's HFDS_MAX_ENTRIES
|
|
8
|
+
CLIENT_HEARTBEAT_SECONDS = 180 # how often the game re-sends "set" while hosting
|
|
9
|
+
|
|
10
|
+
# spoof() mixes these into names like "Leviathan_Outnumbered". No spaces, since the client sends and shows _ as a space.
|
|
11
|
+
# Ship names are picked from the game's NAMES.DAT, which it uses to name the ships in a battle.
|
|
12
|
+
SPOOF_SHIP_NAMES = [
|
|
13
|
+
"Leviathan",
|
|
14
|
+
"Nemesis",
|
|
15
|
+
"Infernal",
|
|
16
|
+
"Behemoth",
|
|
17
|
+
"Cthulhu",
|
|
18
|
+
"Titan",
|
|
19
|
+
"Zeus",
|
|
20
|
+
"Shiva",
|
|
21
|
+
"Yamato",
|
|
22
|
+
"Excalibur",
|
|
23
|
+
"Armageddon",
|
|
24
|
+
"Overlord",
|
|
25
|
+
"Tyrant",
|
|
26
|
+
"Pegasus",
|
|
27
|
+
"Decimator",
|
|
28
|
+
"Merciless",
|
|
29
|
+
"Reaper",
|
|
30
|
+
"Berserker",
|
|
31
|
+
"Gargoyle",
|
|
32
|
+
"Manticore",
|
|
33
|
+
"Chimera",
|
|
34
|
+
"Punisher",
|
|
35
|
+
"Tormentor",
|
|
36
|
+
"Vengeance",
|
|
37
|
+
"Tiamat",
|
|
38
|
+
"Sphinx",
|
|
39
|
+
]
|
|
40
|
+
# Battle names come from the game's .HFB battle files: ESCORT1-3, OUTNUM, SHRIKES, WYVERNS, TIMING, LESSON1-2, NET.
|
|
41
|
+
SPOOF_BATTLE_NAMES = [
|
|
42
|
+
"Escort",
|
|
43
|
+
"Outnumbered",
|
|
44
|
+
"Shrikes",
|
|
45
|
+
"Wyverns",
|
|
46
|
+
"Timing",
|
|
47
|
+
"Lesson",
|
|
48
|
+
"Net",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class HFSession:
|
|
54
|
+
hostname: str = "hfds.dracova.com"
|
|
55
|
+
name: str = "Hell_Fighter_Server"
|
|
56
|
+
location: str = "Unknown"
|
|
57
|
+
connection: str = "Unknown"
|
|
58
|
+
current_players: int = 0
|
|
59
|
+
max_players: int = 24
|
|
60
|
+
flags: int = 0
|
|
61
|
+
version: str = "v21.01"
|
|
62
|
+
|
|
63
|
+
last_seen: float = field(
|
|
64
|
+
default_factory=time.time, metadata={"hf_request_exclude": True}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def heartbeat(self) -> None:
|
|
68
|
+
self.last_seen = time.time()
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def visible_fields(session: "HFSession") -> list[Field]:
|
|
72
|
+
return [f for f in fields(session) if not f.metadata.get("hf_request_exclude")]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HFSessionList:
|
|
76
|
+
"""
|
|
77
|
+
The sessions currently listed.
|
|
78
|
+
|
|
79
|
+
A session's ID is its position in the list. The client asks for IDs 0..count-1 (count being the first "info" int)
|
|
80
|
+
and uses the ID in each record as its table index, so IDs have to stay contiguous. Removing a session shifts the
|
|
81
|
+
ones after it down, which keeps them that way.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
ttl: int = DEFAULT_TTL,
|
|
87
|
+
max_entries: int = DEFAULT_MAX_ENTRIES,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._sessions: list[HFSession] = []
|
|
90
|
+
self._by_host: dict[str, HFSession] = {}
|
|
91
|
+
|
|
92
|
+
self.ttl = ttl
|
|
93
|
+
self.max_entries = max_entries
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def count(self) -> int:
|
|
97
|
+
"""The number of sessions in the list."""
|
|
98
|
+
return len(self._by_host)
|
|
99
|
+
|
|
100
|
+
def expire(self) -> list[HFSession]:
|
|
101
|
+
"""Removes sessions whose heartbeat has not been received since the configured TTL."""
|
|
102
|
+
now = time.time()
|
|
103
|
+
|
|
104
|
+
removed = []
|
|
105
|
+
|
|
106
|
+
# remove() mutates _by_host, so iterate over a copy
|
|
107
|
+
for session in list(self._by_host.values()):
|
|
108
|
+
if now - session.last_seen < self.ttl:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
self.remove(session)
|
|
112
|
+
removed.append(session)
|
|
113
|
+
|
|
114
|
+
return removed
|
|
115
|
+
|
|
116
|
+
def add(self, session: HFSession) -> None:
|
|
117
|
+
"""Adds a session to the list."""
|
|
118
|
+
if session.hostname in self._by_host:
|
|
119
|
+
# Session already exists, so carry on
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if self.count >= self.max_entries:
|
|
123
|
+
Logger.warning(
|
|
124
|
+
f"Full: not adding {session.hostname}, list already has {self.count} sessions"
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
self._by_host[session.hostname] = session
|
|
129
|
+
self._sessions.append(session)
|
|
130
|
+
|
|
131
|
+
def get_by_id(self, ID: int) -> HFSession | None:
|
|
132
|
+
"""Returns a session by ID, if it exists."""
|
|
133
|
+
# a negative ID would otherwise index from the end of the list
|
|
134
|
+
if 0 <= ID < len(self._sessions):
|
|
135
|
+
return self._sessions[ID]
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
def get_by_hostname(self, hostname: str) -> HFSession | None:
|
|
140
|
+
"""Returns a session by hostname, if it exists."""
|
|
141
|
+
if hostname in self._by_host:
|
|
142
|
+
return self._by_host[hostname]
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def remove(self, session: HFSession) -> None:
|
|
147
|
+
"""Removes a session from the list."""
|
|
148
|
+
if session.hostname in self._by_host:
|
|
149
|
+
del self._by_host[session.hostname]
|
|
150
|
+
self._sessions.remove(session)
|
|
151
|
+
|
|
152
|
+
def spoof(self, n: int, seed: int | None = None) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Fills the list with up to n made-up sessions, for trying out the game's server browser without real hosts.
|
|
155
|
+
|
|
156
|
+
The hostnames come from 10.200.0.0/16, which isn't going to be a real host, so joining one won't work.
|
|
157
|
+
Spoofed sessions are never refreshed, so they expire after the TTL like any silent host.
|
|
158
|
+
Pass a seed to get the same sessions every time.
|
|
159
|
+
"""
|
|
160
|
+
rng = random.Random(seed)
|
|
161
|
+
added = 0
|
|
162
|
+
candidate = 0
|
|
163
|
+
|
|
164
|
+
while added < n and self.count < self.max_entries:
|
|
165
|
+
hostname = f"10.200.{candidate // 256}.{candidate % 256}"
|
|
166
|
+
candidate += 1
|
|
167
|
+
|
|
168
|
+
if hostname in self._by_host:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
max_players = rng.choice([8, 16, 24])
|
|
172
|
+
self.add(
|
|
173
|
+
HFSession(
|
|
174
|
+
hostname=hostname,
|
|
175
|
+
name=f"{rng.choice(SPOOF_SHIP_NAMES)}_{rng.choice(SPOOF_BATTLE_NAMES)}",
|
|
176
|
+
location=rng.choice(["Unknown", "North_America", "Europe", "Asia"]),
|
|
177
|
+
connection=rng.choice(["Unknown", "LAN", "Internet", "Modem"]),
|
|
178
|
+
current_players=rng.randint(0, max_players),
|
|
179
|
+
max_players=max_players,
|
|
180
|
+
flags=rng.choice([0, 0, 0, 1]),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
added += 1
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
formatter = logging.Formatter(
|
|
4
|
+
"[%(asctime)s] [%(name)s] [%(levelname)s]: %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
# Importing OpenHFDS must not change the host program's logging, so only the server's main() adds a console handler
|
|
8
|
+
Logger = logging.getLogger("OpenHFDS")
|
|
9
|
+
Logger.addHandler(logging.NullHandler())
|
|
10
|
+
|
|
11
|
+
_console: logging.Handler | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure_logging(level: int | str) -> None:
|
|
15
|
+
"""Sends the logger's output to the console at the given level."""
|
|
16
|
+
global _console
|
|
17
|
+
|
|
18
|
+
if _console is None:
|
|
19
|
+
_console = logging.StreamHandler()
|
|
20
|
+
_console.setFormatter(formatter)
|
|
21
|
+
Logger.addHandler(_console)
|
|
22
|
+
|
|
23
|
+
Logger.setLevel(level)
|
|
24
|
+
_console.setLevel(level)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def log_received_udp_data(data: bytes, addr: tuple[str, int]):
|
|
28
|
+
_log_udp_xfer("Receive", data, addr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_sent_udp_data(data: bytes, addr: tuple[str, int]):
|
|
32
|
+
_log_udp_xfer("Sent", data, addr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _log_udp_xfer(label: str, data: bytes, addr: tuple[str, int]) -> None:
|
|
36
|
+
Logger.info(f"{label}: <{addr[0]}:{addr[1]} {len(data)} {repr(data)}>")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from OpenHFDS.logger import Logger, configure_logging
|
|
4
|
+
from OpenHFDS.hf_client_request import decode_hf_udp, HFUnknownClientRequest
|
|
5
|
+
from OpenHFDS.hf_session import HFSessionList, CLIENT_HEARTBEAT_SECONDS
|
|
6
|
+
from OpenHFDS.cli import parse_args
|
|
7
|
+
from OpenHFDS.version import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def serve(sock: socket.socket, sessions: HFSessionList) -> None:
|
|
11
|
+
while True:
|
|
12
|
+
try:
|
|
13
|
+
for session in sessions.expire():
|
|
14
|
+
Logger.info(
|
|
15
|
+
f"Expire: session <{session.hostname}> last seen at {datetime.fromtimestamp(session.last_seen).isoformat()}"
|
|
16
|
+
)
|
|
17
|
+
except Exception as e:
|
|
18
|
+
Logger.exception(e)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
d, addr = sock.recvfrom(65535)
|
|
22
|
+
except (socket.timeout, ConnectionResetError):
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
request, handler = decode_hf_udp(d, addr)
|
|
26
|
+
handler(request, sessions, sock, addr)
|
|
27
|
+
except HFUnknownClientRequest as e:
|
|
28
|
+
Logger.warning(e)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
Logger.exception(e)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: list[str] | None = None) -> int:
|
|
34
|
+
args = parse_args(argv)
|
|
35
|
+
configure_logging(args.log_level)
|
|
36
|
+
|
|
37
|
+
Logger.info(f"OpenHFDS v{__version__} -- Open HellFighter Directory Server")
|
|
38
|
+
|
|
39
|
+
if args.ttl < CLIENT_HEARTBEAT_SECONDS:
|
|
40
|
+
Logger.warning(
|
|
41
|
+
f"TTL of {args.ttl} s is shorter than the game's {CLIENT_HEARTBEAT_SECONDS} s heartbeat, "
|
|
42
|
+
"so hosts might drop off the list while still running"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
46
|
+
try:
|
|
47
|
+
sock.bind((args.bind, args.port))
|
|
48
|
+
except OSError as e:
|
|
49
|
+
Logger.error(f"Could not listen on {args.bind}:{args.port}: {e}")
|
|
50
|
+
sock.close()
|
|
51
|
+
return 1
|
|
52
|
+
sock.settimeout(0.5)
|
|
53
|
+
|
|
54
|
+
sessions = HFSessionList(ttl=args.ttl, max_entries=args.max_entries)
|
|
55
|
+
Logger.info(
|
|
56
|
+
f"Listening on {args.bind}:{args.port} (ttl {args.ttl}s, up to {args.max_entries} sessions)"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if args.spoof:
|
|
60
|
+
sessions.spoof(args.spoof)
|
|
61
|
+
Logger.info(f"Spoofed {sessions.count} sessions")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
serve(sock, sessions)
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
pass
|
|
67
|
+
finally:
|
|
68
|
+
sock.close()
|
|
69
|
+
|
|
70
|
+
return 0
|
openhfds-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: OpenHFDS
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An open implementation of HellFighter Directory Server
|
|
5
|
+
Project-URL: Homepage, https://github.com/ty-porter/OpenHFDS
|
|
6
|
+
Author-email: Tyler Porter <tyler.b.porter@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: directory-server,hellfighter,udp
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Other Audience
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Other/Nonlisted Topic
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# OpenHFDS
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/OpenHFDS/)
|
|
24
|
+
|
|
25
|
+
OpenHFDS is an open-source session directory server for HellFighter v21.0+ clients. HellFighter uses DirectPlay peering for the actual game code, but the v21.0 client is hard-coded to search for peer sessions at `hfds.dracova.com` which is now no longer serving traffic, so the only way to play online is via direct IP connections.
|
|
26
|
+
|
|
27
|
+
OpenHFDS implements the custom UDP protocol needed to search for HellFighter sessions to enable easy network play.
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
OpenHFDS is dependency-free and only requires Python 3.11 or newer. Installation is as simple as obtaining the source code for this repo and running `python openhfds.py`.
|
|
34
|
+
|
|
35
|
+
If you'd prefer, you can also grab the latest version from PyPi:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install OpenHFDS
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Most people should use the default options provided by OpenHFDS. Unpatched HellFighter clients connect to the HFDS on UDP port 10666.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
python openhfds.py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If you installed the package (for example with `pip install`), the same server is available as the `openhfds` command or `python -m OpenHFDS`.
|
|
50
|
+
|
|
51
|
+
### Connecting the game to your server
|
|
52
|
+
|
|
53
|
+
The game looks for the directory server at the hostname `hfds.dracova.com`, so every player (hosts and joiners) has to make that name point at the machine running OpenHFDS. The simplest way is a `hosts` file entry on each player's computer:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
<address of the OpenHFDS machine> hfds.dracova.com
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The hosts file is `C:\Windows\System32\drivers\etc\hosts` on Windows (edit it as an administrator) and `/etc/hosts` on Linux and macOS.
|
|
60
|
+
|
|
61
|
+
Which address to use depends on where the server runs:
|
|
62
|
+
|
|
63
|
+
- **Same computer as the game:** use `127.0.0.1`. The default `--bind 127.0.0.1` is enough.
|
|
64
|
+
- **Other players on your network or the internet:** start the server with `--bind 0.0.0.0` and use that machine's LAN or public IP. UDP port 10666 must be reachable, so allow it through the firewall and forward it if you're behind a router.
|
|
65
|
+
|
|
66
|
+
OpenHFDS only lists sessions. Once a player picks one, the game connects to the host directly using DirectPlay, so the host's game must also be reachable by the joiner.
|
|
67
|
+
|
|
68
|
+
Players only appear in the list once their game has registered, and a host's listed player count can lag by a few minutes because the game only refreshes it periodically.
|
|
69
|
+
|
|
70
|
+
### All CLI Flags
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
usage: openhfds [-h] [--bind ADDRESS] [--port PORT] [--ttl SECONDS] [--max-entries COUNT] [--spoof COUNT] [--log-level {DEBUG,INFO,WARNING,ERROR}]
|
|
74
|
+
|
|
75
|
+
HellFighter directory server: lists hosted game sessions for the game's server browser.
|
|
76
|
+
|
|
77
|
+
options:
|
|
78
|
+
-h, --help show this help message and exit
|
|
79
|
+
--bind ADDRESS address to listen on; use 0.0.0.0 to accept other machines (default: 127.0.0.1)
|
|
80
|
+
--port PORT UDP port to listen on; the game itself always uses 10666 (default: 10666)
|
|
81
|
+
--ttl SECONDS drop a host after this long without a 'set'; by default the game sends one every 180s (default: 600)
|
|
82
|
+
--max-entries COUNT most sessions to list; the game can't hold more than 4096 (default: 4096)
|
|
83
|
+
--spoof COUNT start with this many made-up sessions, to try out the game's server browser; they can't be joined and expire after --ttl (default: 0)
|
|
84
|
+
--log-level {DEBUG,INFO,WARNING,ERROR}
|
|
85
|
+
logging verbosity; INFO includes every packet received and sent (default: INFO)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Known Issues
|
|
89
|
+
|
|
90
|
+
- **`get` requests can be used for amplification.** A `get` request is a few bytes, but the server answers it with one datagram for every session in the requested range, and there is no rate limiting. UDP source addresses can be forged, so a public server could be used to send traffic at a third party. The impact grows with the number of registered sessions. Keep the default `--bind 127.0.0.1`, or only expose the server to people you trust (for example, a firewall that allows your friends' addresses).
|
|
91
|
+
- **Requests are not authenticated.** The HellFighter protocol has no authentication, and OpenHFDS identifies a host by the IP address its packets come from. Anyone who can send UDP to the server can list, add, change, or delete sessions, including by forging another host's address.
|
|
92
|
+
- **Hosts behind the same public IP address collide.** Because a host is identified by IP address, two people hosting from behind the same router share one entry in the list, and the most recent `set` wins.
|
|
93
|
+
- **The listed player count can be out of date.** The game only re-registers its session every 3 minutes (and when it starts), not when players join or leave.
|
|
94
|
+
|
|
95
|
+
## HFDS Protocol
|
|
96
|
+
|
|
97
|
+
Please see [docs/protocol.md](docs/protocol.md) for details about how the directory server protocol works.
|
|
98
|
+
|
|
99
|
+
## Contributing
|
|
100
|
+
|
|
101
|
+
### Installing Locally
|
|
102
|
+
|
|
103
|
+
OpenHFDS uses `uv` to manage dependencies.
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
uv sync --group dev
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
#### Running Tests
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
uv run python -m unittest
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### Lint
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
uv run black .
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Typechecking
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
uv run mypy .
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Contact
|
|
128
|
+
|
|
129
|
+
Ty Porter
|
|
130
|
+
|
|
131
|
+
tyler.b.porter@gmail.com
|
openhfds-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# OpenHFDS
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/OpenHFDS/)
|
|
4
|
+
|
|
5
|
+
OpenHFDS is an open-source session directory server for HellFighter v21.0+ clients. HellFighter uses DirectPlay peering for the actual game code, but the v21.0 client is hard-coded to search for peer sessions at `hfds.dracova.com` which is now no longer serving traffic, so the only way to play online is via direct IP connections.
|
|
6
|
+
|
|
7
|
+
OpenHFDS implements the custom UDP protocol needed to search for HellFighter sessions to enable easy network play.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
OpenHFDS is dependency-free and only requires Python 3.11 or newer. Installation is as simple as obtaining the source code for this repo and running `python openhfds.py`.
|
|
14
|
+
|
|
15
|
+
If you'd prefer, you can also grab the latest version from PyPi:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install OpenHFDS
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Most people should use the default options provided by OpenHFDS. Unpatched HellFighter clients connect to the HFDS on UDP port 10666.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
python openhfds.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you installed the package (for example with `pip install`), the same server is available as the `openhfds` command or `python -m OpenHFDS`.
|
|
30
|
+
|
|
31
|
+
### Connecting the game to your server
|
|
32
|
+
|
|
33
|
+
The game looks for the directory server at the hostname `hfds.dracova.com`, so every player (hosts and joiners) has to make that name point at the machine running OpenHFDS. The simplest way is a `hosts` file entry on each player's computer:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
<address of the OpenHFDS machine> hfds.dracova.com
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The hosts file is `C:\Windows\System32\drivers\etc\hosts` on Windows (edit it as an administrator) and `/etc/hosts` on Linux and macOS.
|
|
40
|
+
|
|
41
|
+
Which address to use depends on where the server runs:
|
|
42
|
+
|
|
43
|
+
- **Same computer as the game:** use `127.0.0.1`. The default `--bind 127.0.0.1` is enough.
|
|
44
|
+
- **Other players on your network or the internet:** start the server with `--bind 0.0.0.0` and use that machine's LAN or public IP. UDP port 10666 must be reachable, so allow it through the firewall and forward it if you're behind a router.
|
|
45
|
+
|
|
46
|
+
OpenHFDS only lists sessions. Once a player picks one, the game connects to the host directly using DirectPlay, so the host's game must also be reachable by the joiner.
|
|
47
|
+
|
|
48
|
+
Players only appear in the list once their game has registered, and a host's listed player count can lag by a few minutes because the game only refreshes it periodically.
|
|
49
|
+
|
|
50
|
+
### All CLI Flags
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
usage: openhfds [-h] [--bind ADDRESS] [--port PORT] [--ttl SECONDS] [--max-entries COUNT] [--spoof COUNT] [--log-level {DEBUG,INFO,WARNING,ERROR}]
|
|
54
|
+
|
|
55
|
+
HellFighter directory server: lists hosted game sessions for the game's server browser.
|
|
56
|
+
|
|
57
|
+
options:
|
|
58
|
+
-h, --help show this help message and exit
|
|
59
|
+
--bind ADDRESS address to listen on; use 0.0.0.0 to accept other machines (default: 127.0.0.1)
|
|
60
|
+
--port PORT UDP port to listen on; the game itself always uses 10666 (default: 10666)
|
|
61
|
+
--ttl SECONDS drop a host after this long without a 'set'; by default the game sends one every 180s (default: 600)
|
|
62
|
+
--max-entries COUNT most sessions to list; the game can't hold more than 4096 (default: 4096)
|
|
63
|
+
--spoof COUNT start with this many made-up sessions, to try out the game's server browser; they can't be joined and expire after --ttl (default: 0)
|
|
64
|
+
--log-level {DEBUG,INFO,WARNING,ERROR}
|
|
65
|
+
logging verbosity; INFO includes every packet received and sent (default: INFO)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Known Issues
|
|
69
|
+
|
|
70
|
+
- **`get` requests can be used for amplification.** A `get` request is a few bytes, but the server answers it with one datagram for every session in the requested range, and there is no rate limiting. UDP source addresses can be forged, so a public server could be used to send traffic at a third party. The impact grows with the number of registered sessions. Keep the default `--bind 127.0.0.1`, or only expose the server to people you trust (for example, a firewall that allows your friends' addresses).
|
|
71
|
+
- **Requests are not authenticated.** The HellFighter protocol has no authentication, and OpenHFDS identifies a host by the IP address its packets come from. Anyone who can send UDP to the server can list, add, change, or delete sessions, including by forging another host's address.
|
|
72
|
+
- **Hosts behind the same public IP address collide.** Because a host is identified by IP address, two people hosting from behind the same router share one entry in the list, and the most recent `set` wins.
|
|
73
|
+
- **The listed player count can be out of date.** The game only re-registers its session every 3 minutes (and when it starts), not when players join or leave.
|
|
74
|
+
|
|
75
|
+
## HFDS Protocol
|
|
76
|
+
|
|
77
|
+
Please see [docs/protocol.md](docs/protocol.md) for details about how the directory server protocol works.
|
|
78
|
+
|
|
79
|
+
## Contributing
|
|
80
|
+
|
|
81
|
+
### Installing Locally
|
|
82
|
+
|
|
83
|
+
OpenHFDS uses `uv` to manage dependencies.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv sync --group dev
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Running Tests
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
uv run python -m unittest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
#### Lint
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
uv run black .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
#### Typechecking
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
uv run mypy .
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Contact
|
|
108
|
+
|
|
109
|
+
Ty Porter
|
|
110
|
+
|
|
111
|
+
tyler.b.porter@gmail.com
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "OpenHFDS"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
description = "An open implementation of HellFighter Directory Server"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Tyler Porter", email = "tyler.b.porter@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"hellfighter",
|
|
17
|
+
"directory-server",
|
|
18
|
+
"udp",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: Other Audience",
|
|
25
|
+
"Natural Language :: English",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Topic :: Other/Nonlisted Topic",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/ty-porter/OpenHFDS"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
openhfds = "OpenHFDS.server:main"
|
|
36
|
+
|
|
37
|
+
[tool.black]
|
|
38
|
+
target-version = ["py311"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "OpenHFDS/version.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = [
|
|
45
|
+
"OpenHFDS",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"black",
|
|
51
|
+
"mypy"
|
|
52
|
+
]
|