-
Notifications
You must be signed in to change notification settings - Fork 55
feat(cloud_server): implement SSL secure websocket from semver2 into semver3 #723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Boss-1s
wants to merge
23
commits into
TimMcCool:main
Choose a base branch
from
Boss-1s:semver3-secure-ws
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
9863d50
feat(cloud_server): implement SSL secure websocket from semver2 into …
Boss-1s 7e8fddd
fix(cloud_server): expose `init_ssl_cloud_sever` to top-level
Boss-1s bab2905
chore(eventhandlers._base): move mixin class to _base
Boss-1s 5cd379d
fatal(eventhandlers._base): missing imports
Boss-1s 2950297
export cloud server types
Boss-1s a1423ce
fix(sa.eventhandlers): clean up
Boss-1s 8d5aa20
fix(sa.eventhandlers._base): ensure blocked_ips is always some list
Boss-1s 70ffb36
feat: expose BaseCloudServer
Boss-1s 92cc748
fatal: BaseCloudServer should not have ssl-related stuff
Boss-1s 1924636
docstrings
Boss-1s 2a5dfc9
resolve https://github.com/TimMcCool/scratchattach/pull/723#discussio…
Boss-1s 5b0ff22
resolve
Boss-1s b80c260
revert
Boss-1s f81bce7
apply changes
TheCommCraft 1d0bf6e
Merge branch 'TimMcCool:main' into semver3-secure-ws
Boss-1s e905eb8
.
Boss-1s 16b58b7
replace all references to scratchattach/2.0.0
Boss-1s 6243190
fatal: stale assignment to nonexistent arg websocketclass
Boss-1s c476784
Merge branch 'main' into semver3-secure-ws
Boss-1s 65a4ddd
rename `skip_forward` and annotate type
TheCommCraft b77df00
use `serveonce` instead of `serveforever`
TheCommCraft 3b96888
prevent 100% CPU usage
Boss-1s 1b34b7c
revert 3b96888
Boss-1s File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,21 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import time | ||
| import ssl | ||
| from abc import ABC, abstractmethod | ||
| from typing import Optional | ||
| from typing import Optional, Any | ||
| from collections import defaultdict | ||
| from threading import Thread, Event | ||
| from collections.abc import Callable | ||
| import traceback | ||
|
|
||
| from SimpleWebSocketServer import WebSocket | ||
|
|
||
| from scratchattach.utils.requests import requests | ||
| from scratchattach.utils import exceptions | ||
|
|
||
|
|
||
| class BaseEventHandler(ABC): | ||
| _events: defaultdict[str, list[Callable]] | ||
| _threaded_events: defaultdict[str, list[Callable]] | ||
|
|
@@ -41,8 +48,8 @@ def start(self, *, thread=True, ignore_exceptions=True): | |
| else: | ||
| self._thread = None | ||
| self._updater() | ||
| def call_event(self, event_name, args : list = []): | ||
|
|
||
| def call_event(self, event_name, args: list = []): | ||
| try: | ||
| # print(f"Calling for {event_name}...") | ||
| if event_name in self._threaded_events: | ||
|
|
@@ -56,20 +63,18 @@ def call_event(self, event_name, args : list = []): | |
| func(*args) | ||
| except Exception as e: | ||
| if self.ignore_exceptions: | ||
| print( | ||
| f"Warning: Caught error in event '{event_name}' - Full error below" | ||
| ) | ||
| print(f"Warning: Caught error in event '{event_name}' - Full error below") | ||
| try: | ||
| traceback.print_exc() | ||
| except Exception: | ||
| print(e) | ||
| else: | ||
| raise(e) | ||
| raise (e) | ||
|
|
||
| @abstractmethod | ||
| def _updater(self): | ||
| pass | ||
|
|
||
| def __del__(self): | ||
| self.stop() | ||
|
|
||
|
|
@@ -108,6 +113,7 @@ def event(self, function=None, *, thread=False): | |
| """ | ||
| Decorator function. Adds an event. | ||
| """ | ||
|
|
||
| def inner(function): | ||
| # called directly if the decorator provides arguments | ||
| if thread is True: | ||
|
|
@@ -120,4 +126,226 @@ def inner(function): | |
| return inner | ||
| else: | ||
| # => the decorator doesn't provide arguments | ||
| inner(function) | ||
| inner(function) | ||
|
|
||
|
|
||
| class BaseCloudServer(BaseEventHandler): | ||
| """ | ||
| Base class for all sa cloud servers. | ||
|
|
||
| If you are developing a custom cloud server with sa, please inherit from this class | ||
| and change up the methods as needed. | ||
| """ | ||
|
|
||
| hostname: str | ||
| "IP address or domain name of the host to bind the server to." | ||
| port: int | ||
| "Port to bind the server to." | ||
| tw_clients: dict[tuple[str, int], dict[str, Any]] | ||
| "Dictionary containing client information." | ||
| tw_variables: dict[str, dict[str, Any]] | ||
| "Dictionary containing existing cloud variables." | ||
| allow_non_numeric: bool | ||
| "Whether or not non-numeric characters are allowed in cloud variable values." | ||
| whitelisted_projects: set[str] | None | ||
| "Optional list of whitelisted projects." | ||
| length_limit: int | None | ||
| "Optional limit on the length of cloud variable values." | ||
| allow_nonscratch_names: bool | ||
| "Whether or not usernames that do not exist on scratch are allowed." | ||
| blocked_ips: list[str] | ||
| "List of blocked IP addresses." | ||
| sync_players: bool | ||
| log_var_sets: bool | ||
|
|
||
| def __init__( | ||
| self, | ||
| hostname: str, | ||
| *, | ||
| port: int, | ||
| length_limit: int | None = None, | ||
| allow_non_numeric: bool = True, | ||
| whitelisted_projects: list[Any] | None = None, | ||
| allow_nonscratch_names: bool = True, | ||
| blocked_ips: list[str] | None = None, | ||
| sync_players: bool = True, | ||
| log_var_sets: bool = True, | ||
| ): | ||
|
|
||
| if blocked_ips is None: | ||
| blocked_ips = [] | ||
|
|
||
| BaseEventHandler.__init__(self) | ||
|
|
||
| self.tw_clients = {} # saves connected clients | ||
| self.tw_variables = {} # holds cloud variable states | ||
|
|
||
| self.hostname = hostname | ||
| self.port = port | ||
|
|
||
| # server config | ||
| self.allow_non_numeric = allow_non_numeric | ||
| self.whitelisted_projects = ( | ||
| {str(i) for i in whitelisted_projects} if whitelisted_projects else None | ||
| ) | ||
| self.length_limit = length_limit | ||
| self.allow_nonscratch_names = allow_nonscratch_names | ||
| self.blocked_ips = blocked_ips | ||
| self.sync_players = sync_players | ||
| self.log_var_sets = log_var_sets | ||
|
|
||
| def check_for_ip_ban(self, client): | ||
| if ( | ||
| client.address[0] in self.blocked_ips | ||
| or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips | ||
| or client.address in self.blocked_ips | ||
| ): | ||
| client.sendMessage("You have been banned from this server") | ||
| client.close(4002) | ||
| print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") | ||
| return True | ||
| return False | ||
|
|
||
| def active_projects(self): | ||
| only_active = {} | ||
| for project_id in self.tw_variables: | ||
| if self.active_user_ips(project_id) != []: | ||
| only_active[project_id] = self.tw_variables[project_id] | ||
| return only_active | ||
|
|
||
| def active_user_names(self, project_id): | ||
| return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] | ||
|
|
||
| def active_user_ips(self, project_id: Any): | ||
| project_id = str(project_id) | ||
| return [ | ||
| user | ||
| for user in self.tw_clients | ||
| if str(self.tw_clients[user]["project_id"]) == project_id | ||
| ] | ||
|
|
||
| def get_global_vars(self): | ||
| return self.tw_variables | ||
|
|
||
| def get_project_vars(self, project_id: Any): | ||
| project_id = str(project_id) | ||
| return self.tw_variables.get(project_id, {}) | ||
|
|
||
| def get_var(self, project_id: Any, var_name: str, *, no_prefix: bool = False): | ||
| project_id = str(project_id) | ||
| if not no_prefix: | ||
| var_name = "☁ " + var_name.removeprefix("☁ ") | ||
| if project_id in self.tw_variables: | ||
| if var_name in self.tw_variables[project_id]: | ||
| return self.tw_variables[project_id][var_name] | ||
| else: | ||
| return None | ||
| else: | ||
| return None | ||
|
|
||
| def set_global_vars( | ||
| self, | ||
| data: dict[str, dict[str, Any]], | ||
| no_prefix: bool = False, | ||
| ): | ||
| for project_id, project_data in data.items(): | ||
| self.set_project_vars(project_id, project_data, no_prefix=no_prefix) | ||
|
|
||
| def set_project_vars( | ||
| self, | ||
| project_id: Any, | ||
| data: dict[str, Any], | ||
| *, | ||
| user: str = "@server", | ||
| no_prefix: bool = False, | ||
| ): | ||
| project_id = str(project_id) | ||
| if not no_prefix: | ||
| data = {"☁ " + key.removeprefix("☁ "): value for key, value in data.items()} | ||
| self.tw_variables[project_id].update(data) | ||
| for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): | ||
| client.sendMessage( | ||
| "\n".join( | ||
| [ | ||
| json.dumps( | ||
| { | ||
| "method": "set", | ||
| "project_id": project_id, | ||
| "name": varname, | ||
| "value": data[varname], | ||
| "server": "scratchattach/3", | ||
| "timestamp": time.time() * 1000, | ||
| "user": user, | ||
| } | ||
| ) | ||
| for varname in data | ||
| ] | ||
| ) | ||
| ) | ||
|
|
||
| def set_var( | ||
| self, | ||
| project_id: Any, | ||
| var_name: str, | ||
| value: Any, | ||
| *, | ||
| user: str = "@server", | ||
| skip_broadcast_for: WebSocket | None = None, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add documentation or something of the sort for this argument? |
||
| no_prefix: bool = False, | ||
| ): | ||
| if not no_prefix: | ||
| var_name = "☁ " + var_name.removeprefix("☁ ") | ||
| project_id = str(project_id) | ||
| if project_id not in self.tw_variables: | ||
| self.tw_variables[project_id] = {} | ||
| self.tw_variables[project_id][var_name] = value | ||
|
|
||
| if self.sync_players is True: | ||
| for client in ( | ||
| self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id) | ||
| ): | ||
| if client == skip_broadcast_for: | ||
| continue | ||
| client.sendMessage( | ||
| json.dumps( | ||
| { | ||
| "method": "set", | ||
| "project_id": project_id, | ||
| "name": var_name, | ||
| "value": value, | ||
| "timestamp": time.time() * 1000, | ||
| "user": user, | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| def _check_value(self, value): | ||
| # Checks if a received cloud value satisfies the server's constraints | ||
| if self.length_limit is not None: | ||
| if len(str(value)) > self.length_limit: | ||
| return False | ||
| if self.allow_non_numeric is False: | ||
| x = value.replace(".", "") | ||
| x = x.replace("-", "") | ||
| if not (x.isnumeric() or x == ""): | ||
| return False | ||
| return True | ||
|
|
||
| def _updater(self): | ||
| try: | ||
| # Function called when .start() is executed (.start is inherited from BaseEventHandler) | ||
| print(f"Serving websocket server: ws://{self.hostname}:{self.port}") | ||
| while self.running: | ||
| self.serveonce() | ||
| except Exception as e: | ||
| raise exceptions.WebsocketServerError(str(e)) | ||
|
|
||
| def pause(self): | ||
| self.running = False | ||
|
|
||
| def resume(self): | ||
| self.running = True | ||
|
|
||
| def stop(self, wait_call_threads: bool = True): | ||
| BaseEventHandler.stop(self, wait_call_threads) | ||
| self.close() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.