local-control 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ """
2
+ Local Control package exposing the web server and CLI helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ __all__ = ["create_app", "__version__"]
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .app import create_app # noqa: E402 (lazy import to avoid side effects)
local_control/app.py ADDED
@@ -0,0 +1,291 @@
1
+ """
2
+ Flask application wiring the authentication manager, control handlers,
3
+ and static frontend.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, Optional, Tuple
12
+
13
+ from flask import (
14
+ Flask,
15
+ Response,
16
+ jsonify,
17
+ make_response,
18
+ request,
19
+ send_from_directory,
20
+ )
21
+
22
+ from .auth import AuthManager, CredentialError, RateLimitError
23
+ from . import control, clipboard
24
+ from .clipboard import ClipboardData
25
+
26
+ SESSION_COOKIE = "session_token"
27
+ TRUSTED_COOKIE = "trusted_token"
28
+ SESSION_MAX_AGE = 60 * 60 * 4 # 4 hours
29
+ TRUSTED_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
30
+
31
+ LOG = logging.getLogger(__name__)
32
+
33
+
34
+ def create_app(auth_manager: Optional[AuthManager] = None) -> Flask:
35
+ static_dir = Path(__file__).parent / "static"
36
+ app = Flask(
37
+ __name__,
38
+ static_folder=str(static_dir),
39
+ static_url_path="/static",
40
+ )
41
+ app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
42
+ auth = auth_manager or AuthManager()
43
+
44
+ def client_key() -> str:
45
+ return request.remote_addr or "unknown"
46
+
47
+ def ensure_session() -> Tuple[Optional[str], Optional[str]]:
48
+ token = request.cookies.get(SESSION_COOKIE)
49
+ user = auth.session_user(token)
50
+ if user:
51
+ return user, None
52
+
53
+ trusted = request.cookies.get(TRUSTED_COOKIE)
54
+ user = auth.auto_login(trusted)
55
+ if user:
56
+ fresh = auth.create_session(user)
57
+ return user, fresh
58
+ return None, None
59
+
60
+ def require_auth() -> Tuple[Optional[str], Optional[str]]:
61
+ user, new_token = ensure_session()
62
+ if not user:
63
+ return None, None
64
+ return user, new_token
65
+
66
+ def build_response(
67
+ payload: Dict[str, Any],
68
+ session_token: Optional[str] = None,
69
+ clear_session: bool = False,
70
+ trusted_token: Optional[str] = None,
71
+ clear_trusted: bool = False,
72
+ status: int = 200,
73
+ ) -> Response:
74
+ response = make_response(jsonify(payload), status)
75
+ if session_token:
76
+ response.set_cookie(
77
+ SESSION_COOKIE,
78
+ session_token,
79
+ httponly=True,
80
+ secure=False,
81
+ samesite="Strict",
82
+ max_age=SESSION_MAX_AGE,
83
+ )
84
+ if clear_session:
85
+ response.delete_cookie(SESSION_COOKIE)
86
+ if trusted_token:
87
+ response.set_cookie(
88
+ TRUSTED_COOKIE,
89
+ trusted_token,
90
+ httponly=True,
91
+ secure=False,
92
+ samesite="Strict",
93
+ max_age=TRUSTED_MAX_AGE,
94
+ )
95
+ if clear_trusted:
96
+ response.delete_cookie(TRUSTED_COOKIE)
97
+ return response
98
+
99
+ # Routes -----------------------------------------------------------------
100
+ @app.get("/")
101
+ def index() -> Response:
102
+ return send_from_directory(app.static_folder, "index.html")
103
+
104
+ @app.get("/api/session")
105
+ def session_info() -> Response:
106
+ user, fresh_token = ensure_session()
107
+ if user:
108
+ return build_response(
109
+ {"authenticated": True, "username": user},
110
+ session_token=fresh_token,
111
+ )
112
+ return build_response({"authenticated": False, "username": None})
113
+
114
+ @app.post("/api/login")
115
+ def login() -> Response:
116
+ data = request.get_json(silent=True) or {}
117
+ username = str(data.get("username", "")).strip()
118
+ password = str(data.get("password", ""))
119
+ remember = bool(data.get("remember", False))
120
+ remote = client_key()
121
+
122
+ try:
123
+ auth.check_rate_limit(remote)
124
+ except RateLimitError as exc:
125
+ return build_response({"error": str(exc)}, status=429)
126
+
127
+ try:
128
+ verified = auth.verify_credentials(username, password)
129
+ except CredentialError as exc:
130
+ LOG.warning("Credential verification error: %s", exc)
131
+ return build_response({"error": str(exc)}, status=400)
132
+
133
+ if not verified:
134
+ auth.register_failure(remote)
135
+ return build_response({"error": "Invalid username or password."}, status=401)
136
+
137
+ auth.register_success(remote)
138
+ session_token = auth.create_session(username)
139
+ trusted_token = auth.create_trusted_token(username) if remember else None
140
+
141
+ payload = {"authenticated": True, "username": username}
142
+ return build_response(
143
+ payload,
144
+ session_token=session_token,
145
+ trusted_token=trusted_token,
146
+ )
147
+
148
+ @app.post("/api/logout")
149
+ def logout() -> Response:
150
+ token = request.cookies.get(SESSION_COOKIE)
151
+ if token:
152
+ auth.destroy_session(token)
153
+ return build_response({"authenticated": False}, clear_session=True)
154
+
155
+ def auth_endpoint(handler: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Response:
156
+ user, fresh_token = require_auth()
157
+ if not user:
158
+ return build_response({"error": "Authentication required."}, status=401)
159
+ payload = request.get_json(silent=True) or {}
160
+ try:
161
+ response_payload = handler(payload)
162
+ except ValueError as exc:
163
+ return build_response({"error": str(exc)}, status=400)
164
+ except Exception as exc: # pragma: no cover - defensive
165
+ LOG.exception("Handler error: %s", exc)
166
+ return build_response({"error": str(exc)}, status=500)
167
+ return build_response(response_payload, session_token=fresh_token)
168
+
169
+ @app.post("/api/mouse/move")
170
+ def mouse_move() -> Response:
171
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
172
+ dx = float(data.get("dx", 0.0))
173
+ dy = float(data.get("dy", 0.0))
174
+ state = control.move_cursor(dx, dy)
175
+ return {"status": "ok", "state": state}
176
+
177
+ return auth_endpoint(action)
178
+
179
+ @app.post("/api/mouse/click")
180
+ def mouse_click() -> Response:
181
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
182
+ button = str(data.get("button", "left"))
183
+ double = bool(data.get("double", False))
184
+ control.click(button=button, double=double)
185
+ return {"status": "ok"}
186
+
187
+ return auth_endpoint(action)
188
+
189
+ @app.post("/api/mouse/button")
190
+ def mouse_button() -> Response:
191
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
192
+ button = str(data.get("button", "left"))
193
+ button_action = str(data.get("action", "down"))
194
+ control.button_action(button=button, action=button_action)
195
+ return {"status": "ok"}
196
+
197
+ return auth_endpoint(action)
198
+
199
+ @app.post("/api/mouse/scroll")
200
+ def mouse_scroll() -> Response:
201
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
202
+ vertical = float(data.get("vertical", 0.0))
203
+ horizontal = float(data.get("horizontal", 0.0))
204
+ control.scroll(vertical=vertical, horizontal=horizontal)
205
+ return {"status": "ok"}
206
+
207
+ return auth_endpoint(action)
208
+
209
+ @app.get("/api/mouse/state")
210
+ def mouse_state() -> Response:
211
+ user, fresh_token = require_auth()
212
+ if not user:
213
+ return build_response({"error": "Authentication required."}, status=401)
214
+ state = control.cursor_state()
215
+ return build_response({"status": "ok", "state": state}, session_token=fresh_token)
216
+
217
+ @app.post("/api/keyboard/type")
218
+ def keyboard_type() -> Response:
219
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
220
+ text = str(data.get("text", ""))
221
+ control.type_text(text)
222
+ return {"status": "ok"}
223
+
224
+ return auth_endpoint(action)
225
+
226
+ @app.post("/api/keyboard/key")
227
+ def keyboard_key() -> Response:
228
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
229
+ key = str(data.get("key", "")).lower()
230
+ action_name = str(data.get("action", "press")).lower()
231
+ control.key_action(key, action_name)
232
+ return {"status": "ok"}
233
+
234
+ return auth_endpoint(action)
235
+
236
+ @app.post("/api/system/lock")
237
+ def system_lock() -> Response:
238
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
239
+ control.lock_screen()
240
+ return {"status": "ok"}
241
+
242
+ return auth_endpoint(action)
243
+
244
+ @app.post("/api/system/unlock")
245
+ def system_unlock() -> Response:
246
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
247
+ control.unlock_screen()
248
+ return {"status": "ok"}
249
+
250
+ return auth_endpoint(action)
251
+
252
+ @app.post("/api/system/shutdown")
253
+ def system_shutdown() -> Response:
254
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
255
+ control.shutdown_system()
256
+ return {"status": "ok"}
257
+
258
+ return auth_endpoint(action)
259
+
260
+ @app.get("/api/clipboard")
261
+ def clipboard_read() -> Response:
262
+ user, fresh_token = require_auth()
263
+ if not user:
264
+ return build_response({"error": "Authentication required."}, status=401)
265
+ clip = clipboard.get_clipboard()
266
+ if clip:
267
+ content = {"type": clip.kind, "data": clip.data, "mime": clip.mime}
268
+ else:
269
+ content = None
270
+ return build_response(
271
+ {"status": "ok", "content": content},
272
+ session_token=fresh_token,
273
+ )
274
+
275
+ @app.post("/api/clipboard")
276
+ def clipboard_write() -> Response:
277
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
278
+ clip_type = str(data.get("type", "")).lower()
279
+ if clip_type not in {"text", "image"}:
280
+ raise ValueError("Clipboard type must be 'text' or 'image'.")
281
+ payload = data.get("data")
282
+ if payload is None:
283
+ raise ValueError("Clipboard payload missing.")
284
+ mime = data.get("mime")
285
+ clip = ClipboardData(kind=clip_type, data=str(payload), mime=str(mime) if mime else None)
286
+ clipboard.set_clipboard(clip)
287
+ return {"status": "ok"}
288
+
289
+ return auth_endpoint(action)
290
+
291
+ return app
local_control/auth.py ADDED
@@ -0,0 +1,240 @@
1
+ """
2
+ Authentication manager handling OS credential checks, sessions, device trust,
3
+ and brute-force protections.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import getpass
9
+ import hashlib
10
+ import secrets
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional
17
+
18
+ from .config import data_dir, load_json, save_json
19
+
20
+
21
+ class RateLimitError(Exception):
22
+ """Raised when a client exceeds the allowed login attempts."""
23
+
24
+
25
+ class CredentialError(Exception):
26
+ """Raised when provided credentials cannot be validated."""
27
+
28
+
29
+ @dataclass
30
+ class Session:
31
+ username: str
32
+ created_at: float
33
+
34
+
35
+ @dataclass
36
+ class TrustedDevice:
37
+ username: str
38
+ token_hash: str
39
+ created_at: float
40
+
41
+
42
+ class AuthManager:
43
+ MAX_ATTEMPTS = 5
44
+ WINDOW_SECONDS = 600
45
+ LOCKOUT_SECONDS = 600
46
+
47
+ def __init__(self) -> None:
48
+ base = data_dir()
49
+ self._secret_path = base / "secret.key"
50
+ self._trusted_path = base / "trusted_devices.json"
51
+ self._secret = self._load_secret()
52
+ self._sessions: Dict[str, Session] = {}
53
+ self._trusted_devices: List[TrustedDevice] = self._load_trusted_devices()
54
+ self._attempts: Dict[str, Dict[str, float]] = {}
55
+ self._current_user = getpass.getuser()
56
+
57
+ @property
58
+ def current_user(self) -> str:
59
+ return self._current_user
60
+
61
+ # Secret helpers ---------------------------------------------------------
62
+ def _load_secret(self) -> bytes:
63
+ if self._secret_path.exists():
64
+ return self._secret_path.read_bytes()
65
+
66
+ secret = secrets.token_bytes(32)
67
+ self._secret_path.write_bytes(secret)
68
+ return secret
69
+
70
+ # Trusted device persistence ---------------------------------------------
71
+ def _load_trusted_devices(self) -> List[TrustedDevice]:
72
+ data = load_json(
73
+ self._trusted_path,
74
+ default={"devices": []},
75
+ )
76
+ devices: List[TrustedDevice] = []
77
+ for entry in data.get("devices", []):
78
+ try:
79
+ devices.append(
80
+ TrustedDevice(
81
+ username=entry["username"],
82
+ token_hash=entry["token_hash"],
83
+ created_at=float(entry.get("created_at", 0.0)),
84
+ )
85
+ )
86
+ except KeyError:
87
+ continue
88
+ return devices
89
+
90
+ def _persist_trusted(self) -> None:
91
+ payload = {
92
+ "devices": [
93
+ {
94
+ "username": device.username,
95
+ "token_hash": device.token_hash,
96
+ "created_at": device.created_at,
97
+ }
98
+ for device in self._trusted_devices
99
+ ]
100
+ }
101
+ save_json(self._trusted_path, payload)
102
+
103
+ # Rate limiting ----------------------------------------------------------
104
+ def check_rate_limit(self, key: str) -> None:
105
+ entry = self._attempts.get(key)
106
+ now = time.time()
107
+ if not entry:
108
+ return
109
+
110
+ locked_until = entry.get("locked_until", 0.0)
111
+ if locked_until and locked_until > now:
112
+ raise RateLimitError("Too many attempts. Retry later.")
113
+
114
+ timestamps = entry.get("timestamps", [])
115
+ # Drop attempts outside of the time window.
116
+ timestamps = [ts for ts in timestamps if now - ts < self.WINDOW_SECONDS]
117
+ entry["timestamps"] = timestamps
118
+ if len(timestamps) >= self.MAX_ATTEMPTS:
119
+ entry["locked_until"] = now + self.LOCKOUT_SECONDS
120
+ raise RateLimitError("Too many attempts. Retry later.")
121
+
122
+ def register_failure(self, key: str) -> None:
123
+ now = time.time()
124
+ entry = self._attempts.setdefault(
125
+ key, {"timestamps": [], "locked_until": 0.0}
126
+ )
127
+ entry.setdefault("timestamps", []).append(now)
128
+ # Enforce the window retention.
129
+ entry["timestamps"] = [
130
+ ts for ts in entry["timestamps"] if now - ts < self.WINDOW_SECONDS
131
+ ]
132
+ if len(entry["timestamps"]) >= self.MAX_ATTEMPTS:
133
+ entry["locked_until"] = now + self.LOCKOUT_SECONDS
134
+
135
+ def register_success(self, key: str) -> None:
136
+ if key in self._attempts:
137
+ del self._attempts[key]
138
+
139
+ # Token helpers ----------------------------------------------------------
140
+ def _hash_token(self, token: str) -> str:
141
+ return hashlib.sha256(self._secret + token.encode("utf-8")).hexdigest()
142
+
143
+ def create_session(self, username: str) -> str:
144
+ token = secrets.token_urlsafe(32)
145
+ token_hash = self._hash_token(token)
146
+ self._sessions[token_hash] = Session(username=username, created_at=time.time())
147
+ return token
148
+
149
+ def destroy_session(self, token: str) -> None:
150
+ token_hash = self._hash_token(token)
151
+ self._sessions.pop(token_hash, None)
152
+
153
+ def session_user(self, token: Optional[str]) -> Optional[str]:
154
+ if not token:
155
+ return None
156
+ token_hash = self._hash_token(token)
157
+ session = self._sessions.get(token_hash)
158
+ if session:
159
+ return session.username
160
+ return None
161
+
162
+ # Trusted devices --------------------------------------------------------
163
+ def create_trusted_token(self, username: str) -> str:
164
+ token = secrets.token_urlsafe(32)
165
+ token_hash = self._hash_token(token)
166
+ self._trusted_devices.append(
167
+ TrustedDevice(username=username, token_hash=token_hash, created_at=time.time())
168
+ )
169
+ self._persist_trusted()
170
+ return token
171
+
172
+ def auto_login(self, token: Optional[str]) -> Optional[str]:
173
+ if not token:
174
+ return None
175
+ token_hash = self._hash_token(token)
176
+ for device in self._trusted_devices:
177
+ if device.token_hash == token_hash and device.username == self._current_user:
178
+ return device.username
179
+ return None
180
+
181
+ # Credential verification ------------------------------------------------
182
+ def verify_credentials(self, username: str, password: str) -> bool:
183
+ if username != self._current_user:
184
+ return False
185
+
186
+ system = sys.platform
187
+ if system.startswith("win"):
188
+ return self._verify_windows(username, password)
189
+ return self._verify_unix(username, password)
190
+
191
+ def _verify_unix(self, username: str, password: str) -> bool:
192
+ # Use sudo in non-interactive mode. Requires the user to be part of sudoers.
193
+ if not password:
194
+ return False
195
+ try:
196
+ subprocess.run(
197
+ ["sudo", "-k"],
198
+ stdout=subprocess.DEVNULL,
199
+ stderr=subprocess.DEVNULL,
200
+ check=False,
201
+ )
202
+ proc = subprocess.run(
203
+ ["sudo", "-S", "-p", "", "true"],
204
+ input=f"{password}\n".encode("utf-8"),
205
+ stdout=subprocess.DEVNULL,
206
+ stderr=subprocess.PIPE,
207
+ check=False,
208
+ timeout=10,
209
+ )
210
+ return proc.returncode == 0
211
+ except (OSError, subprocess.SubprocessError):
212
+ raise CredentialError(
213
+ "Could not verify credentials on this platform. Ensure sudo is available."
214
+ )
215
+
216
+ def _verify_windows(self, username: str, password: str) -> bool:
217
+ if not password:
218
+ return False
219
+ try:
220
+ import ctypes
221
+ from ctypes import wintypes
222
+ except ImportError as exc: # pragma: no cover
223
+ raise CredentialError("Windows credential APIs are unavailable.") from exc
224
+
225
+ LOGON32_LOGON_INTERACTIVE = 2
226
+ LOGON32_PROVIDER_DEFAULT = 0
227
+
228
+ handle = wintypes.HANDLE()
229
+ result = ctypes.windll.advapi32.LogonUserW(
230
+ ctypes.c_wchar_p(username),
231
+ ctypes.c_wchar_p(None),
232
+ ctypes.c_wchar_p(password),
233
+ LOGON32_LOGON_INTERACTIVE,
234
+ LOGON32_PROVIDER_DEFAULT,
235
+ ctypes.byref(handle),
236
+ )
237
+ if result:
238
+ ctypes.windll.kernel32.CloseHandle(handle)
239
+ return True
240
+ return False
local_control/cli.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Command-line entry point for launching the local control server.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import logging
9
+ import os
10
+ import socket
11
+ import sys
12
+ from typing import Iterable, List, Optional
13
+
14
+ from .app import create_app
15
+ from .startup import StartupManager, StartupError
16
+ from .utils.terminal_qr import render_text
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ description="Start the local Control server to steer this machine remotely.",
22
+ )
23
+ parser.add_argument(
24
+ "--host",
25
+ default="0.0.0.0",
26
+ help="Host/IP to bind (default: 0.0.0.0 for all interfaces).",
27
+ )
28
+ parser.add_argument(
29
+ "--port",
30
+ type=int,
31
+ default=4001,
32
+ help="Port to listen on (default: 4001).",
33
+ )
34
+ parser.add_argument(
35
+ "--debug",
36
+ action="store_true",
37
+ help="Enable Flask debug mode.",
38
+ )
39
+ parser.add_argument(
40
+ "--startup",
41
+ action="store_true",
42
+ help="Register this server to launch automatically for the current user.",
43
+ )
44
+ parser.add_argument(
45
+ "--startup-cancel",
46
+ action="store_true",
47
+ help="Remove the auto-start registration created with --startup.",
48
+ )
49
+ return parser
50
+
51
+
52
+ def main(argv: Optional[Iterable[str]] = None) -> None:
53
+ parser = build_parser()
54
+ args = parser.parse_args(list(argv) if argv is not None else None)
55
+
56
+ if args.startup and args.startup_cancel:
57
+ parser.error("Choose only one of --startup or --startup-cancel.")
58
+
59
+ if args.startup or args.startup_cancel:
60
+ manager = StartupManager(args.host, args.port, args.debug)
61
+ try:
62
+ if args.startup:
63
+ manager.enable()
64
+ print("Startup entry created. Local Control will launch on login.")
65
+ else:
66
+ manager.disable()
67
+ print("Startup entry removed.")
68
+ except StartupError as exc:
69
+ print(f"Startup configuration failed: {exc}", file=sys.stderr)
70
+ sys.exit(1)
71
+ return
72
+
73
+ logging.basicConfig(level=logging.INFO)
74
+ app = create_app()
75
+ if not args.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
76
+ _display_banner(args.host, args.port)
77
+ app.run(host=args.host, port=args.port, debug=args.debug)
78
+
79
+
80
+ def _display_banner(host: str, port: int) -> None:
81
+ urls = _candidate_urls(host, port)
82
+ if not urls:
83
+ return
84
+ primary = next((url for url in urls if not url.startswith("http://127.")), urls[0])
85
+
86
+ print("Local Control server ready. Reach it at:", flush=True)
87
+ for url in urls:
88
+ print(f" • {url}", flush=True)
89
+
90
+ try:
91
+ qr_text = render_text(primary)
92
+ except Exception as exc: # pragma: no cover - cosmetic
93
+ logging.debug("Failed to render QR code: %s", exc)
94
+ return
95
+
96
+ print("\nScan to connect:", flush=True)
97
+ print(qr_text, flush=True)
98
+
99
+
100
+ def _candidate_urls(host: str, port: int) -> List[str]:
101
+ hosts: List[str] = []
102
+ if host in {"0.0.0.0", "::", "", "*"}:
103
+ hosts.extend(_local_ipv4_addresses())
104
+ hosts.append("127.0.0.1")
105
+ hosts.append("localhost")
106
+ else:
107
+ hosts.append(host)
108
+ if host not in {"127.0.0.1", "localhost"}:
109
+ hosts.append("127.0.0.1")
110
+ hosts.append("localhost")
111
+
112
+ normalized = []
113
+ for h in hosts:
114
+ if ":" in h and not h.startswith("["):
115
+ normalized.append(f"http://[{h}]:{port}")
116
+ else:
117
+ normalized.append(f"http://{h}:{port}")
118
+
119
+ seen = set()
120
+ unique_urls = []
121
+ for url in normalized:
122
+ if url not in seen:
123
+ seen.add(url)
124
+ unique_urls.append(url)
125
+ return unique_urls
126
+
127
+
128
+ def _local_ipv4_addresses() -> List[str]:
129
+ candidates = set()
130
+ try:
131
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
132
+ sock.connect(("8.8.8.8", 80))
133
+ candidates.add(sock.getsockname()[0])
134
+ except OSError:
135
+ pass
136
+ try:
137
+ hostname = socket.gethostname()
138
+ for addr in socket.gethostbyname_ex(hostname)[2]:
139
+ if addr and addr != "127.0.0.1":
140
+ candidates.add(addr)
141
+ except socket.gaierror:
142
+ pass
143
+ return sorted(candidates)