dark-chat 2.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.
- dark_chat-2.1.0/MANIFEST.in +4 -0
- dark_chat-2.1.0/PKG-INFO +150 -0
- dark_chat-2.1.0/README.md +134 -0
- dark_chat-2.1.0/docs/alwaysdata.md +97 -0
- dark_chat-2.1.0/docs/distribution.md +44 -0
- dark_chat-2.1.0/pyproject.toml +32 -0
- dark_chat-2.1.0/scripts/run_server.py +58 -0
- dark_chat-2.1.0/scripts/update.py +141 -0
- dark_chat-2.1.0/setup.cfg +4 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/PKG-INFO +150 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/SOURCES.txt +20 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/dependency_links.txt +1 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/entry_points.txt +3 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/requires.txt +3 -0
- dark_chat-2.1.0/src/dark_chat.egg-info/top_level.txt +1 -0
- dark_chat-2.1.0/src/dark_terminal_chat/__init__.py +3 -0
- dark_chat-2.1.0/src/dark_terminal_chat/__main__.py +3 -0
- dark_chat-2.1.0/src/dark_terminal_chat/client.py +406 -0
- dark_chat-2.1.0/src/dark_terminal_chat/protocol.py +105 -0
- dark_chat-2.1.0/src/dark_terminal_chat/server.py +247 -0
- dark_chat-2.1.0/tests/test_deployment.py +156 -0
- dark_chat-2.1.0/tests/test_python_chat.py +523 -0
dark_chat-2.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dark-chat
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Minimal two-person encrypted terminal chat and WebSocket relay
|
|
5
|
+
Project-URL: Repository, https://github.com/iZubayr/dark-terminal-chat
|
|
6
|
+
Project-URL: Issues, https://github.com/iZubayr/dark-terminal-chat/issues
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: Communications :: Chat
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: cryptography<50,>=44
|
|
14
|
+
Requires-Dist: websockets<18,>=15
|
|
15
|
+
Requires-Dist: prompt-toolkit<4,>=3.0.48
|
|
16
|
+
|
|
17
|
+
# Dark Terminal Chat
|
|
18
|
+
|
|
19
|
+
A plain, English terminal chat for two people. No banner, logo, animation, or forced color. Python 3.10 or newer is required. The client and WebSocket server install from the same Python package.
|
|
20
|
+
|
|
21
|
+
Version 2 uses a new session protocol. Use version 2 clients and servers together. Source: [GitHub](https://github.com/iZubayr/dark-terminal-chat).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Install from [PyPI](https://pypi.org/project/dark-chat/):
|
|
26
|
+
|
|
27
|
+
~~~sh
|
|
28
|
+
pip install dark-chat
|
|
29
|
+
~~~
|
|
30
|
+
|
|
31
|
+
Create a chat:
|
|
32
|
+
|
|
33
|
+
~~~sh
|
|
34
|
+
dark-chat --new
|
|
35
|
+
~~~
|
|
36
|
+
|
|
37
|
+
Join a chat:
|
|
38
|
+
|
|
39
|
+
~~~sh
|
|
40
|
+
dark-chat --chat
|
|
41
|
+
~~~
|
|
42
|
+
|
|
43
|
+
Enter your name when asked. The creator receives a code and shares it with the peer. The peer enters that code when asked. Both commands connect to the hosted server automatically.
|
|
44
|
+
|
|
45
|
+
For development, install from this folder on Windows:
|
|
46
|
+
|
|
47
|
+
~~~powershell
|
|
48
|
+
python -m venv .venv
|
|
49
|
+
.\.venv\Scripts\Activate.ps1
|
|
50
|
+
python -m pip install .
|
|
51
|
+
~~~
|
|
52
|
+
|
|
53
|
+
On Linux or macOS:
|
|
54
|
+
|
|
55
|
+
~~~sh
|
|
56
|
+
python3 -m venv .venv
|
|
57
|
+
. .venv/bin/activate
|
|
58
|
+
python -m pip install .
|
|
59
|
+
~~~
|
|
60
|
+
|
|
61
|
+
The package provides two commands: dark-chat and dark-chat-server. You can also use python -m dark_terminal_chat and python -m dark_terminal_chat.server.
|
|
62
|
+
|
|
63
|
+
## Local use
|
|
64
|
+
|
|
65
|
+
Open three terminals with the Python environment activated.
|
|
66
|
+
|
|
67
|
+
~~~sh
|
|
68
|
+
# Server
|
|
69
|
+
dark-chat-server
|
|
70
|
+
|
|
71
|
+
# Creator
|
|
72
|
+
dark-chat --server ws://127.0.0.1:8080/ws --new --name elliot
|
|
73
|
+
|
|
74
|
+
# Peer, in another terminal
|
|
75
|
+
dark-chat --server ws://127.0.0.1:8080/ws --chat --name whiterose
|
|
76
|
+
~~~
|
|
77
|
+
|
|
78
|
+
The creator receives a line starting with "Code:". The peer pastes that code at "Code:". The code is hidden while entering it. If no name is supplied, the program asks "Name:". Running dark-chat without either mode asks "Create or join? [c/j]:".
|
|
79
|
+
|
|
80
|
+
The interface contains only prompts, messages, and relevant connection/error notices. Example:
|
|
81
|
+
|
|
82
|
+
~~~text
|
|
83
|
+
Code: <invite code>
|
|
84
|
+
Waiting for peer.
|
|
85
|
+
whiterose joined.
|
|
86
|
+
whiterose> Hello.
|
|
87
|
+
elliot>
|
|
88
|
+
~~~
|
|
89
|
+
|
|
90
|
+
On Windows, chat.cmd and server.cmd use this folder's .venv when available.
|
|
91
|
+
|
|
92
|
+
## Session rules
|
|
93
|
+
|
|
94
|
+
- Each chat has two participant slots. A third participant is refused.
|
|
95
|
+
- Leaving with /exit or Ctrl+C releases the participant's slot immediately while connected.
|
|
96
|
+
- When both participants leave, the room is removed. Joining with its old code fails; create a new chat for a new code.
|
|
97
|
+
- A dropped connection reserves the same participant's slot for 60 seconds. Only that participant's in-memory resume token can reclaim it.
|
|
98
|
+
- The client tries to reconnect for up to 60 seconds. When a server restarts, room state is lost and a new chat is needed.
|
|
99
|
+
- Sending is paused while the peer is offline. The draft remains editable.
|
|
100
|
+
- Messages are not queued or automatically resent. If receipt isn't confirmed, the draft is kept and a short notice is shown. A lost receipt can mean the peer already saw the message; check before manually resending.
|
|
101
|
+
- Messages and drafts are not written to files. Old messages are not replayed after reconnecting.
|
|
102
|
+
|
|
103
|
+
## Internet use
|
|
104
|
+
|
|
105
|
+
The deployed server is available at wss://zubayr.alwaysdata.net/dark-chat/ws:
|
|
106
|
+
|
|
107
|
+
~~~sh
|
|
108
|
+
dark-chat --new
|
|
109
|
+
dark-chat --chat
|
|
110
|
+
~~~
|
|
111
|
+
|
|
112
|
+
Share the code with the peer. To use your own relay, pass --server wss://YOUR_SERVER/ws. Use wss:// for internet connections. TLS certificates are checked; --ca ca.pem supports a private certificate authority.
|
|
113
|
+
|
|
114
|
+
DARK_CHAT_SERVER can override the default relay. --server overrides that environment variable. DARK_CHAT_CODE is supported for non-interactive clients; interactive clients ask for the code instead.
|
|
115
|
+
|
|
116
|
+
For local Wi-Fi, use --host 0.0.0.0 on the server and --server ws://LAN_IP:8080/ws --allow-insecure on clients.
|
|
117
|
+
|
|
118
|
+
[AlwaysData setup beside MediaHub](docs/alwaysdata.md).
|
|
119
|
+
|
|
120
|
+
## Commands
|
|
121
|
+
|
|
122
|
+
| Command | Action |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| /help | List commands |
|
|
125
|
+
| /who | Show the connected participants whose names are known |
|
|
126
|
+
| /clear | Clear the screen |
|
|
127
|
+
| /exit or Ctrl+C | Leave |
|
|
128
|
+
|
|
129
|
+
## Privacy
|
|
130
|
+
|
|
131
|
+
Messages and names are encrypted on the client with AES-256-GCM. Keys are derived from a random 32-byte invite code with HKDF-SHA256. The invite code is never sent to the relay. The relay sees opaque room/session identifiers, IP addresses, message sizes, timing, and delivery-control metadata.
|
|
132
|
+
|
|
133
|
+
An invite code is shared access, and a name is not verified identity. Keep the code private. The terminal scrollback may retain displayed messages and the creator's code; /clear is not secure erasure. There is no forward secrecy, anonymity guarantee, or independent security audit.
|
|
134
|
+
|
|
135
|
+
The relay holds only session metadata and temporary network buffers. There is no message history or application-level message queue. The default server limit is 128 connections/rooms, with two participant slots per room and a 40-packet limit per connection per 10 seconds. Run one relay process; separate instances do not share rooms.
|
|
136
|
+
|
|
137
|
+
## Build and check
|
|
138
|
+
|
|
139
|
+
~~~sh
|
|
140
|
+
python -m pip install -e . build twine
|
|
141
|
+
python -m unittest discover -s tests -v
|
|
142
|
+
python -m build
|
|
143
|
+
python -m twine check dist/*
|
|
144
|
+
~~~
|
|
145
|
+
|
|
146
|
+
[Package distribution](docs/distribution.md).
|
|
147
|
+
|
|
148
|
+
GitHub Actions tests the installed wheel on Linux with Python 3.10/3.12 and on Windows with Python 3.12. After all jobs pass, it advances the deploy branch. AlwaysData checks that branch every five minutes and tests the candidate again before changing the running version. See [automatic updates](docs/alwaysdata.md#automatic-updates).
|
|
149
|
+
|
|
150
|
+
The earlier Node.js TCP experiment remains as reference source only. It does not support the current session protocol. The Python client/server is the application.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Dark Terminal Chat
|
|
2
|
+
|
|
3
|
+
A plain, English terminal chat for two people. No banner, logo, animation, or forced color. Python 3.10 or newer is required. The client and WebSocket server install from the same Python package.
|
|
4
|
+
|
|
5
|
+
Version 2 uses a new session protocol. Use version 2 clients and servers together. Source: [GitHub](https://github.com/iZubayr/dark-terminal-chat).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install from [PyPI](https://pypi.org/project/dark-chat/):
|
|
10
|
+
|
|
11
|
+
~~~sh
|
|
12
|
+
pip install dark-chat
|
|
13
|
+
~~~
|
|
14
|
+
|
|
15
|
+
Create a chat:
|
|
16
|
+
|
|
17
|
+
~~~sh
|
|
18
|
+
dark-chat --new
|
|
19
|
+
~~~
|
|
20
|
+
|
|
21
|
+
Join a chat:
|
|
22
|
+
|
|
23
|
+
~~~sh
|
|
24
|
+
dark-chat --chat
|
|
25
|
+
~~~
|
|
26
|
+
|
|
27
|
+
Enter your name when asked. The creator receives a code and shares it with the peer. The peer enters that code when asked. Both commands connect to the hosted server automatically.
|
|
28
|
+
|
|
29
|
+
For development, install from this folder on Windows:
|
|
30
|
+
|
|
31
|
+
~~~powershell
|
|
32
|
+
python -m venv .venv
|
|
33
|
+
.\.venv\Scripts\Activate.ps1
|
|
34
|
+
python -m pip install .
|
|
35
|
+
~~~
|
|
36
|
+
|
|
37
|
+
On Linux or macOS:
|
|
38
|
+
|
|
39
|
+
~~~sh
|
|
40
|
+
python3 -m venv .venv
|
|
41
|
+
. .venv/bin/activate
|
|
42
|
+
python -m pip install .
|
|
43
|
+
~~~
|
|
44
|
+
|
|
45
|
+
The package provides two commands: dark-chat and dark-chat-server. You can also use python -m dark_terminal_chat and python -m dark_terminal_chat.server.
|
|
46
|
+
|
|
47
|
+
## Local use
|
|
48
|
+
|
|
49
|
+
Open three terminals with the Python environment activated.
|
|
50
|
+
|
|
51
|
+
~~~sh
|
|
52
|
+
# Server
|
|
53
|
+
dark-chat-server
|
|
54
|
+
|
|
55
|
+
# Creator
|
|
56
|
+
dark-chat --server ws://127.0.0.1:8080/ws --new --name elliot
|
|
57
|
+
|
|
58
|
+
# Peer, in another terminal
|
|
59
|
+
dark-chat --server ws://127.0.0.1:8080/ws --chat --name whiterose
|
|
60
|
+
~~~
|
|
61
|
+
|
|
62
|
+
The creator receives a line starting with "Code:". The peer pastes that code at "Code:". The code is hidden while entering it. If no name is supplied, the program asks "Name:". Running dark-chat without either mode asks "Create or join? [c/j]:".
|
|
63
|
+
|
|
64
|
+
The interface contains only prompts, messages, and relevant connection/error notices. Example:
|
|
65
|
+
|
|
66
|
+
~~~text
|
|
67
|
+
Code: <invite code>
|
|
68
|
+
Waiting for peer.
|
|
69
|
+
whiterose joined.
|
|
70
|
+
whiterose> Hello.
|
|
71
|
+
elliot>
|
|
72
|
+
~~~
|
|
73
|
+
|
|
74
|
+
On Windows, chat.cmd and server.cmd use this folder's .venv when available.
|
|
75
|
+
|
|
76
|
+
## Session rules
|
|
77
|
+
|
|
78
|
+
- Each chat has two participant slots. A third participant is refused.
|
|
79
|
+
- Leaving with /exit or Ctrl+C releases the participant's slot immediately while connected.
|
|
80
|
+
- When both participants leave, the room is removed. Joining with its old code fails; create a new chat for a new code.
|
|
81
|
+
- A dropped connection reserves the same participant's slot for 60 seconds. Only that participant's in-memory resume token can reclaim it.
|
|
82
|
+
- The client tries to reconnect for up to 60 seconds. When a server restarts, room state is lost and a new chat is needed.
|
|
83
|
+
- Sending is paused while the peer is offline. The draft remains editable.
|
|
84
|
+
- Messages are not queued or automatically resent. If receipt isn't confirmed, the draft is kept and a short notice is shown. A lost receipt can mean the peer already saw the message; check before manually resending.
|
|
85
|
+
- Messages and drafts are not written to files. Old messages are not replayed after reconnecting.
|
|
86
|
+
|
|
87
|
+
## Internet use
|
|
88
|
+
|
|
89
|
+
The deployed server is available at wss://zubayr.alwaysdata.net/dark-chat/ws:
|
|
90
|
+
|
|
91
|
+
~~~sh
|
|
92
|
+
dark-chat --new
|
|
93
|
+
dark-chat --chat
|
|
94
|
+
~~~
|
|
95
|
+
|
|
96
|
+
Share the code with the peer. To use your own relay, pass --server wss://YOUR_SERVER/ws. Use wss:// for internet connections. TLS certificates are checked; --ca ca.pem supports a private certificate authority.
|
|
97
|
+
|
|
98
|
+
DARK_CHAT_SERVER can override the default relay. --server overrides that environment variable. DARK_CHAT_CODE is supported for non-interactive clients; interactive clients ask for the code instead.
|
|
99
|
+
|
|
100
|
+
For local Wi-Fi, use --host 0.0.0.0 on the server and --server ws://LAN_IP:8080/ws --allow-insecure on clients.
|
|
101
|
+
|
|
102
|
+
[AlwaysData setup beside MediaHub](docs/alwaysdata.md).
|
|
103
|
+
|
|
104
|
+
## Commands
|
|
105
|
+
|
|
106
|
+
| Command | Action |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| /help | List commands |
|
|
109
|
+
| /who | Show the connected participants whose names are known |
|
|
110
|
+
| /clear | Clear the screen |
|
|
111
|
+
| /exit or Ctrl+C | Leave |
|
|
112
|
+
|
|
113
|
+
## Privacy
|
|
114
|
+
|
|
115
|
+
Messages and names are encrypted on the client with AES-256-GCM. Keys are derived from a random 32-byte invite code with HKDF-SHA256. The invite code is never sent to the relay. The relay sees opaque room/session identifiers, IP addresses, message sizes, timing, and delivery-control metadata.
|
|
116
|
+
|
|
117
|
+
An invite code is shared access, and a name is not verified identity. Keep the code private. The terminal scrollback may retain displayed messages and the creator's code; /clear is not secure erasure. There is no forward secrecy, anonymity guarantee, or independent security audit.
|
|
118
|
+
|
|
119
|
+
The relay holds only session metadata and temporary network buffers. There is no message history or application-level message queue. The default server limit is 128 connections/rooms, with two participant slots per room and a 40-packet limit per connection per 10 seconds. Run one relay process; separate instances do not share rooms.
|
|
120
|
+
|
|
121
|
+
## Build and check
|
|
122
|
+
|
|
123
|
+
~~~sh
|
|
124
|
+
python -m pip install -e . build twine
|
|
125
|
+
python -m unittest discover -s tests -v
|
|
126
|
+
python -m build
|
|
127
|
+
python -m twine check dist/*
|
|
128
|
+
~~~
|
|
129
|
+
|
|
130
|
+
[Package distribution](docs/distribution.md).
|
|
131
|
+
|
|
132
|
+
GitHub Actions tests the installed wheel on Linux with Python 3.10/3.12 and on Windows with Python 3.12. After all jobs pass, it advances the deploy branch. AlwaysData checks that branch every five minutes and tests the candidate again before changing the running version. See [automatic updates](docs/alwaysdata.md#automatic-updates).
|
|
133
|
+
|
|
134
|
+
The earlier Node.js TCP experiment remains as reference source only. It does not support the current session protocol. The Python client/server is the application.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# AlwaysData deployment
|
|
2
|
+
|
|
3
|
+
Deploy this chat as a separate site, directory, and virtual environment beside MediaHub. The applications share the account's resource allowance. The hosting supervisor limits the relay to 32 connections.
|
|
4
|
+
|
|
5
|
+
AlwaysData supports [WebSocket sites](https://help.alwaysdata.com/en/blog/2023-03-14-hold-on-to-your-socks-high-speed-data-stream-hosting-with-websockets/). External clients use HTTPS/WSS, while the relay listens on the site's internal IP/port.
|
|
6
|
+
|
|
7
|
+
## Deployed instance
|
|
8
|
+
|
|
9
|
+
The zubayr account serves the chat at wss://zubayr.alwaysdata.net/dark-chat/ws. Health: https://zubayr.alwaysdata.net/dark-chat/health. Its clone lives at /home/zubayr/dark-chat. The separate Dark Terminal site is 1084884; scheduled task 34302 checks for updates every five minutes. MediaHub remains the root site.
|
|
10
|
+
|
|
11
|
+
On 2026-10-08, the hosting environment passed all 22 tests. Two clients installed from the GitHub release exchanged messages through the public WSS endpoint. The same check verified encrypted Unicode messages, refusal of a third participant, session resumption, and rejection of an old invite after both participants left. MediaHub's /health returned HTTP 200 before and after the check.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
In the AlwaysData SSH terminal (replace ACCOUNT with the account name):
|
|
16
|
+
|
|
17
|
+
~~~sh
|
|
18
|
+
cd /home/ACCOUNT
|
|
19
|
+
git clone --branch deploy https://github.com/iZubayr/dark-terminal-chat.git dark-chat
|
|
20
|
+
cd dark-chat
|
|
21
|
+
python3 scripts/update.py
|
|
22
|
+
.deploy/current/venv/bin/dark-chat-server --version
|
|
23
|
+
~~~
|
|
24
|
+
|
|
25
|
+
Python 3.10+ is required. GitHub Actions advances the deploy branch only after every test job passes. The updater installs that approved commit and tests it again in its own environment. The deploy branch becomes available after the first successful Tests workflow.
|
|
26
|
+
|
|
27
|
+
## Add a site
|
|
28
|
+
|
|
29
|
+
In the AlwaysData panel, choose Web → Sites → Add a site. See [User program configuration](https://help.alwaysdata.com/en/docs/web-hosting/sites/http-servers/user-program/).
|
|
30
|
+
|
|
31
|
+
| Field | Value |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| Name | Dark Terminal |
|
|
34
|
+
| Addresses | ACCOUNT.alwaysdata.net/dark-chat |
|
|
35
|
+
| Type | User program |
|
|
36
|
+
| Command | python3 /home/ACCOUNT/dark-chat/scripts/run_server.py |
|
|
37
|
+
| Working directory | /home/ACCOUNT/dark-chat |
|
|
38
|
+
| Environment | IP and PORT: the values assigned to this new site in the panel |
|
|
39
|
+
| Trim path | Enabled |
|
|
40
|
+
| Idle time | 0 |
|
|
41
|
+
|
|
42
|
+
Replace ACCOUNT with your actual account name. If IP/PORT are not supplied automatically, set them in Environment or pass --host PANEL_IP --port PANEL_PORT in Command. Use the new site's assigned port.
|
|
43
|
+
|
|
44
|
+
Trim path maps external /dark-chat/ws to internal /ws, and /dark-chat/health to /health. AlwaysData handles external TLS. Start/restart the new chat site.
|
|
45
|
+
|
|
46
|
+
## Verify
|
|
47
|
+
|
|
48
|
+
~~~sh
|
|
49
|
+
curl --fail https://ACCOUNT.alwaysdata.net/dark-chat/health
|
|
50
|
+
~~~
|
|
51
|
+
|
|
52
|
+
Expected response:
|
|
53
|
+
|
|
54
|
+
~~~json
|
|
55
|
+
{"status":"ok"}
|
|
56
|
+
~~~
|
|
57
|
+
|
|
58
|
+
Then use two computers:
|
|
59
|
+
|
|
60
|
+
~~~sh
|
|
61
|
+
dark-chat --server wss://ACCOUNT.alwaysdata.net/dark-chat/ws --new --name elliot
|
|
62
|
+
dark-chat --server wss://ACCOUNT.alwaysdata.net/dark-chat/ws --name whiterose
|
|
63
|
+
~~~
|
|
64
|
+
|
|
65
|
+
The creator shares the invite code with the peer. Confirm messages arrive in both directions, a third client is refused, and MediaHub still responds normally. Local tests do not prove production behavior.
|
|
66
|
+
|
|
67
|
+
## Restarts and upgrades
|
|
68
|
+
|
|
69
|
+
Use version 2 clients with a version 2 server. The session protocol is incompatible with version 1.
|
|
70
|
+
|
|
71
|
+
Run one relay process. Rooms are in memory, so a server restart ends existing sessions and invalidates join codes. Clients handle ordinary connection drops for 60 seconds; they cannot reconstruct rooms after a server restart.
|
|
72
|
+
|
|
73
|
+
For a 503 response, check this site's startup log, command, environment, and IP/PORT. If health works but chat doesn't, check the public WSS URL, Trim path, TLS, and site settings affecting WebSocket connections.
|
|
74
|
+
|
|
75
|
+
## Automatic updates
|
|
76
|
+
|
|
77
|
+
Add a separate AlwaysData scheduled task:
|
|
78
|
+
|
|
79
|
+
| Field | Value |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| Type | Command |
|
|
82
|
+
| Frequency | Every 5 minutes |
|
|
83
|
+
| Command | See the bootstrap command below |
|
|
84
|
+
| Working directory | /home/ACCOUNT/dark-chat |
|
|
85
|
+
| Annotation | Dark Terminal updates |
|
|
86
|
+
|
|
87
|
+
Scheduled task command (replace ACCOUNT):
|
|
88
|
+
|
|
89
|
+
~~~sh
|
|
90
|
+
/bin/bash -c 'set -eu; cd /home/ACCOUNT/dark-chat; git fetch origin main deploy; mkdir -p .deploy; git show origin/deploy:scripts/update.py > .deploy/update-current.py; exec python3 .deploy/update-current.py'
|
|
91
|
+
~~~
|
|
92
|
+
|
|
93
|
+
The bootstrap uses the approved upstream updater, so fixes to deployment itself can take effect. The updater fetches the deploy branch published by GitHub Tests, verifies that its commit belongs to main, and builds a separate release environment. Before building, it removes inactive generated release environments inside this clone to fit the hosting disk quota; the current release is preserved. It runs the tests there before fast-forwarding the clone and atomically changing .deploy/current. Pip runs in isolated mode with --no-user and --no-cache-dir. The supervisor then restarts only the chat child process. No GitHub SSH private key, API token, or unauthenticated GitHub API call is needed on the server.
|
|
94
|
+
|
|
95
|
+
Failed tests, failed installs, local edits, or diverged commits leave the active release unchanged. A lock prevents overlapping updates. The current release and one candidate are retained; older environments can be rebuilt from Git if needed. A successful upgrade ends any current chats because rooms exist only in memory.
|
|
96
|
+
|
|
97
|
+
For a manual update, run python3 scripts/update.py from the clone. The scheduled task uses the same path. To roll back, point .deploy/current at a known previous release; the supervisor notices the change. Disable the scheduled task while investigating so it does not immediately reapply main.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Distributing dark-chat
|
|
2
|
+
|
|
3
|
+
The package name and client command are both dark-chat. Python 3.10+ is required. Version 2.1.0 connects to wss://zubayr.alwaysdata.net/dark-chat/ws by default.
|
|
4
|
+
|
|
5
|
+
The public client commands are:
|
|
6
|
+
|
|
7
|
+
~~~sh
|
|
8
|
+
pip install dark-chat
|
|
9
|
+
dark-chat --new
|
|
10
|
+
dark-chat --chat
|
|
11
|
+
~~~
|
|
12
|
+
|
|
13
|
+
The creator shares the code with the peer. Names and codes are entered at the terminal prompts. The server address is optional; --server and DARK_CHAT_SERVER support other relays.
|
|
14
|
+
|
|
15
|
+
## Build and verify
|
|
16
|
+
|
|
17
|
+
~~~sh
|
|
18
|
+
python -m build --outdir dist/2.1.0
|
|
19
|
+
python -m twine check dist/2.1.0/*
|
|
20
|
+
python -m pip install dist/2.1.0/dark_chat-2.1.0-py3-none-any.whl
|
|
21
|
+
python -m pip check
|
|
22
|
+
python -m unittest discover -s tests -v
|
|
23
|
+
dark-chat --version
|
|
24
|
+
~~~
|
|
25
|
+
|
|
26
|
+
Keep earlier dark-terminal-chat distribution files separate. Publish only the new dark-chat distribution. The import module remains dark_terminal_chat, so a client environment should contain one of these distributions at a time.
|
|
27
|
+
|
|
28
|
+
## PyPI publishing
|
|
29
|
+
|
|
30
|
+
Configure a pending GitHub Trusted Publisher under [PyPI account publishing](https://pypi.org/manage/account/publishing/):
|
|
31
|
+
|
|
32
|
+
| Field | Value |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| PyPI project name | dark-chat |
|
|
35
|
+
| GitHub owner | iZubayr |
|
|
36
|
+
| Repository | dark-terminal-chat |
|
|
37
|
+
| Workflow filename | ci.yml |
|
|
38
|
+
| Environment | pypi |
|
|
39
|
+
|
|
40
|
+
Then run the Tests workflow on main with the publish input enabled. It builds and installs the package, runs tests on Linux with Python 3.10/3.12 and Windows with Python 3.12, and checks the installed dark-chat command. Only after all jobs pass does the publish job upload the tested Linux 3.12 artifact to PyPI using short-lived OIDC credentials. No permanent PyPI API token is stored in the repository or on AlwaysData.
|
|
41
|
+
|
|
42
|
+
After publishing, install dark-chat from the normal PyPI index in a fresh environment. Verify --new and --chat exchange messages through the default public relay. A built wheel or successful GitHub workflow without a successful PyPI publish does not establish that pip install dark-chat works.
|
|
43
|
+
|
|
44
|
+
[PyPI Trusted Publishing documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dark-chat"
|
|
7
|
+
version = "2.1.0"
|
|
8
|
+
description = "Minimal two-person encrypted terminal chat and WebSocket relay"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"cryptography>=44,<50",
|
|
13
|
+
"websockets>=15,<18",
|
|
14
|
+
"prompt-toolkit>=3.0.48,<4",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Communications :: Chat",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
dark-chat = "dark_terminal_chat.client:main"
|
|
25
|
+
dark-chat-server = "dark_terminal_chat.server:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/iZubayr/dark-terminal-chat"
|
|
29
|
+
Issues = "https://github.com/iZubayr/dark-terminal-chat/issues"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""AlwaysData supervises this process; it supervises the active chat release."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def release_target():
|
|
12
|
+
release = (ROOT / ".deploy/current").resolve(strict=True)
|
|
13
|
+
release.relative_to((ROOT / ".deploy/releases").resolve())
|
|
14
|
+
if not (release / "venv/bin/python").is_file():
|
|
15
|
+
raise ValueError("Active release has no Python runtime")
|
|
16
|
+
return release
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def stop_child(child):
|
|
20
|
+
if child.poll() is not None:
|
|
21
|
+
return
|
|
22
|
+
child.terminate()
|
|
23
|
+
try:
|
|
24
|
+
child.wait(timeout=10)
|
|
25
|
+
except subprocess.TimeoutExpired:
|
|
26
|
+
child.kill()
|
|
27
|
+
child.wait()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main():
|
|
31
|
+
stopped = threading.Event()
|
|
32
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
33
|
+
signal.signal(sig, lambda *_: stopped.set())
|
|
34
|
+
child = None
|
|
35
|
+
try:
|
|
36
|
+
while not stopped.is_set():
|
|
37
|
+
release = release_target()
|
|
38
|
+
environment = dict(os.environ, PYTHONUNBUFFERED="1")
|
|
39
|
+
child = subprocess.Popen(
|
|
40
|
+
[str(release / "venv/bin/python"), "-m", "dark_terminal_chat.server",
|
|
41
|
+
"--max-clients", "32"], cwd=release, env=environment,
|
|
42
|
+
)
|
|
43
|
+
print(f"Running release: {release.name}", flush=True)
|
|
44
|
+
while not stopped.wait(2):
|
|
45
|
+
status = child.poll()
|
|
46
|
+
if status is not None:
|
|
47
|
+
raise SystemExit(status or 1)
|
|
48
|
+
if release_target() != release:
|
|
49
|
+
break
|
|
50
|
+
stop_child(child)
|
|
51
|
+
child = None
|
|
52
|
+
finally:
|
|
53
|
+
if child is not None:
|
|
54
|
+
stop_child(child)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Fast-forward the server clone only after CI and a staged install pass."""
|
|
2
|
+
import argparse
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tarfile
|
|
11
|
+
import venv
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def command(*args, cwd=ROOT, capture=False):
|
|
17
|
+
return subprocess.run(args, cwd=cwd, check=True, text=True,
|
|
18
|
+
stdout=subprocess.PIPE if capture else None).stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_release(root=ROOT):
|
|
22
|
+
current = root / ".deploy/current"
|
|
23
|
+
if not current.exists():
|
|
24
|
+
if current.is_symlink():
|
|
25
|
+
raise ValueError("Active release is missing; cleanup refused")
|
|
26
|
+
return None
|
|
27
|
+
target = current.resolve(strict=True)
|
|
28
|
+
releases = (root / ".deploy/releases").resolve()
|
|
29
|
+
releases.relative_to(root.resolve())
|
|
30
|
+
if target.parent != releases or not re.fullmatch(r"[0-9a-f]{40}", target.name):
|
|
31
|
+
raise ValueError("Invalid active release")
|
|
32
|
+
return target
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def activate(release, root=ROOT):
|
|
36
|
+
current = root / ".deploy/current"
|
|
37
|
+
pending = root / ".deploy/current.next"
|
|
38
|
+
with contextlib.suppress(FileNotFoundError):
|
|
39
|
+
pending.unlink()
|
|
40
|
+
pending.symlink_to(release, target_is_directory=True)
|
|
41
|
+
os.replace(pending, current)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def prune_releases(root=ROOT):
|
|
45
|
+
releases = root / ".deploy/releases"
|
|
46
|
+
if releases.is_symlink():
|
|
47
|
+
raise ValueError("Release directory must not be a symlink")
|
|
48
|
+
boundary = releases.resolve()
|
|
49
|
+
boundary.relative_to(root.resolve())
|
|
50
|
+
if not releases.exists():
|
|
51
|
+
return
|
|
52
|
+
active = current_release(root)
|
|
53
|
+
for candidate in releases.iterdir():
|
|
54
|
+
if (candidate.is_symlink() or not candidate.is_dir()
|
|
55
|
+
or not re.fullmatch(r"[0-9a-f]{40}", candidate.name)):
|
|
56
|
+
continue
|
|
57
|
+
resolved = candidate.resolve(strict=True)
|
|
58
|
+
resolved.relative_to(boundary)
|
|
59
|
+
if resolved == active:
|
|
60
|
+
continue
|
|
61
|
+
# These directories contain generated installs, rebuildable from Git.
|
|
62
|
+
shutil.rmtree(resolved)
|
|
63
|
+
print(f"Removed inactive release: {candidate.name}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stage(revision, root=ROOT):
|
|
67
|
+
# The free hosting quota cannot retain an unbounded number of environments.
|
|
68
|
+
prune_releases(root)
|
|
69
|
+
release = root / ".deploy/releases" / revision
|
|
70
|
+
release.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
source = release / "code"
|
|
72
|
+
source.mkdir(exist_ok=True)
|
|
73
|
+
archive = subprocess.run(["git", "archive", revision], cwd=root, check=True,
|
|
74
|
+
stdout=subprocess.PIPE).stdout
|
|
75
|
+
with tarfile.open(fileobj=io.BytesIO(archive)) as tree:
|
|
76
|
+
for member in tree.getmembers():
|
|
77
|
+
if member.issym() or member.islnk() or not (member.isfile() or member.isdir()):
|
|
78
|
+
raise ValueError("Unexpected file in release archive")
|
|
79
|
+
(source / member.name).resolve().relative_to(source.resolve())
|
|
80
|
+
if hasattr(tarfile, "data_filter"):
|
|
81
|
+
tree.extractall(source, filter="data")
|
|
82
|
+
else:
|
|
83
|
+
# Older Python 3.10 versions use the path/type checks above.
|
|
84
|
+
tree.extractall(source)
|
|
85
|
+
environment = release / "venv"
|
|
86
|
+
venv.EnvBuilder(with_pip=True).create(environment)
|
|
87
|
+
python = environment / "bin/python"
|
|
88
|
+
# AlwaysData defaults pip to --user; this release has its own environment.
|
|
89
|
+
command(str(python), "-m", "pip", "--isolated", "install", "--no-user", "--no-cache-dir", "--timeout", "120", str(source), cwd=source)
|
|
90
|
+
command(str(python), "-m", "pip", "check", cwd=source)
|
|
91
|
+
command(str(python), "-m", "unittest", "discover", "-s", "tests", "-v", cwd=source)
|
|
92
|
+
return release
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def update(root=ROOT):
|
|
96
|
+
if os.name != "posix":
|
|
97
|
+
raise ValueError("Run the updater on the Linux hosting server")
|
|
98
|
+
import fcntl
|
|
99
|
+
state = root / ".deploy"
|
|
100
|
+
state.mkdir(exist_ok=True)
|
|
101
|
+
with (state / "update.lock").open("w") as lock:
|
|
102
|
+
try:
|
|
103
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
104
|
+
except BlockingIOError:
|
|
105
|
+
print("Another update is running.")
|
|
106
|
+
return
|
|
107
|
+
if command("git", "status", "--porcelain", cwd=root, capture=True).strip():
|
|
108
|
+
raise ValueError("Local checkout has edits; update refused")
|
|
109
|
+
origin = command("git", "remote", "get-url", "origin", cwd=root, capture=True).strip()
|
|
110
|
+
match = re.fullmatch(r"https://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?", origin)
|
|
111
|
+
if not match:
|
|
112
|
+
raise ValueError("Origin must be a public GitHub HTTPS repository")
|
|
113
|
+
# CI alone advances deploy after every test job succeeds. No API token
|
|
114
|
+
# or shared hosting API rate limit is involved in this checkout.
|
|
115
|
+
command("git", "fetch", "origin", "main", "deploy", cwd=root)
|
|
116
|
+
revision = command("git", "rev-parse", "origin/deploy", cwd=root, capture=True).strip()
|
|
117
|
+
if not re.fullmatch(r"[0-9a-f]{40}", revision):
|
|
118
|
+
raise ValueError("Invalid upstream revision")
|
|
119
|
+
command("git", "merge-base", "--is-ancestor", revision, "origin/main", cwd=root)
|
|
120
|
+
previous = current_release(root)
|
|
121
|
+
if previous and previous.name == revision:
|
|
122
|
+
print(f"Already current: {revision}")
|
|
123
|
+
return
|
|
124
|
+
command("git", "merge-base", "--is-ancestor", "HEAD", revision, cwd=root)
|
|
125
|
+
release = stage(revision, root)
|
|
126
|
+
command("git", "merge", "--ff-only", revision, cwd=root)
|
|
127
|
+
activate(release, root)
|
|
128
|
+
print(f"Activated: {revision}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main():
|
|
132
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
133
|
+
parser.parse_args()
|
|
134
|
+
try:
|
|
135
|
+
update()
|
|
136
|
+
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
|
137
|
+
raise SystemExit(f"Update failed: {error}") from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|