mirror of
https://github.com/QIDITECH/moonraker.git
synced 2026-01-30 16:18:44 +03:00
update v1.7.0
This commit is contained in:
968
moonraker/authorization.py
Normal file
968
moonraker/authorization.py
Normal file
@@ -0,0 +1,968 @@
|
||||
# API Key Based Authorization
|
||||
#
|
||||
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
||||
#
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
import os
|
||||
import time
|
||||
import datetime
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import logging
|
||||
from tornado.web import HTTPError
|
||||
from libnacl.sign import Signer, Verifier
|
||||
from ..utils import json_wrapper as jsonw
|
||||
from ..common import RequestType, TransportType, SqlTableDefinition, UserInfo
|
||||
|
||||
# Annotation imports
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Tuple,
|
||||
Optional,
|
||||
Union,
|
||||
Dict,
|
||||
List,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..confighelper import ConfigHelper
|
||||
from ..common import WebRequest
|
||||
from .websockets import WebsocketManager
|
||||
from tornado.httputil import HTTPServerRequest
|
||||
from .database import MoonrakerDatabase as DBComp
|
||||
from .database import DBProviderWrapper
|
||||
from .ldap import MoonrakerLDAP
|
||||
IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
||||
IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
|
||||
OneshotToken = Tuple[IPAddr, Optional[UserInfo], asyncio.Handle]
|
||||
|
||||
# Helpers for base64url encoding and decoding
|
||||
def base64url_encode(data: bytes) -> bytes:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=")
|
||||
|
||||
def base64url_decode(data: str) -> bytes:
|
||||
pad_cnt = len(data) % 4
|
||||
if pad_cnt:
|
||||
data += "=" * (4 - pad_cnt)
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
|
||||
ONESHOT_TIMEOUT = 5
|
||||
TRUSTED_CONNECTION_TIMEOUT = 3600
|
||||
FQDN_CACHE_TIMEOUT = 84000
|
||||
PRUNE_CHECK_TIME = 300.
|
||||
|
||||
USER_TABLE = "authorized_users"
|
||||
AUTH_SOURCES = ["moonraker", "ldap"]
|
||||
HASH_ITER = 100000
|
||||
API_USER = "_API_KEY_USER_"
|
||||
TRUSTED_USER = "_TRUSTED_USER_"
|
||||
RESERVED_USERS = [API_USER, TRUSTED_USER]
|
||||
JWT_EXP_TIME = datetime.timedelta(hours=1)
|
||||
JWT_HEADER = {
|
||||
'alg': "EdDSA",
|
||||
'typ': "JWT"
|
||||
}
|
||||
|
||||
class UserSqlDefinition(SqlTableDefinition):
|
||||
name = USER_TABLE
|
||||
prototype = (
|
||||
f"""
|
||||
{USER_TABLE} (
|
||||
username TEXT PRIMARY KEY NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_on REAL NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
jwt_secret TEXT,
|
||||
jwk_id TEXT,
|
||||
groups pyjson
|
||||
)
|
||||
"""
|
||||
)
|
||||
version = 1
|
||||
|
||||
def migrate(self, last_version: int, db_provider: DBProviderWrapper) -> None:
|
||||
if last_version == 0:
|
||||
users: Dict[str, Dict[str, Any]]
|
||||
users = db_provider.get_namespace("authorized_users")
|
||||
api_user = users.pop(API_USER, {})
|
||||
if not isinstance(api_user, dict):
|
||||
api_user = {}
|
||||
user_vals: List[Tuple[Any, ...]] = [
|
||||
UserInfo(
|
||||
username=API_USER,
|
||||
password=api_user.get("api_key", uuid.uuid4().hex),
|
||||
created_on=api_user.get("created_on", time.time())
|
||||
).as_tuple()
|
||||
]
|
||||
for key, user in users.items():
|
||||
if not isinstance(user, dict):
|
||||
logging.info(
|
||||
f"Auth migration, skipping invalid value: {key} {user}"
|
||||
)
|
||||
continue
|
||||
user_vals.append(UserInfo(**user).as_tuple())
|
||||
placeholders = ",".join("?" * len(user_vals[0]))
|
||||
conn = db_provider.connection
|
||||
with conn:
|
||||
conn.executemany(
|
||||
f"INSERT OR IGNORE INTO {USER_TABLE} VALUES({placeholders})",
|
||||
user_vals
|
||||
)
|
||||
db_provider.wipe_local_namespace("authorized_users")
|
||||
|
||||
class Authorization:
|
||||
def __init__(self, config: ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.login_timeout = config.getint('login_timeout', 90)
|
||||
self.force_logins = config.getboolean('force_logins', False)
|
||||
self.default_source = config.get('default_source', "moonraker").lower()
|
||||
self.enable_api_key = config.getboolean('enable_api_key', True)
|
||||
self.max_logins = config.getint("max_login_attempts", None, above=0)
|
||||
self.failed_logins: Dict[IPAddr, int] = {}
|
||||
self.fqdn_cache: Dict[IPAddr, Dict[str, Any]] = {}
|
||||
if self.default_source not in AUTH_SOURCES:
|
||||
self.server.add_warning(
|
||||
"[authorization]: option 'default_source' - Invalid "
|
||||
f"value '{self.default_source}', falling back to "
|
||||
"'moonraker'."
|
||||
)
|
||||
self.default_source = "moonraker"
|
||||
self.ldap: Optional[MoonrakerLDAP] = None
|
||||
if config.has_section("ldap"):
|
||||
self.ldap = self.server.load_component(config, "ldap", None)
|
||||
if self.default_source == "ldap" and self.ldap is None:
|
||||
self.server.add_warning(
|
||||
"[authorization]: Option 'default_source' set to 'ldap',"
|
||||
" however [ldap] section failed to load or not configured"
|
||||
)
|
||||
database: DBComp = self.server.lookup_component('database')
|
||||
self.user_table = database.register_table(UserSqlDefinition())
|
||||
self.users: Dict[str, UserInfo] = {}
|
||||
self.api_key = uuid.uuid4().hex
|
||||
hi = self.server.get_host_info()
|
||||
self.issuer = f"http://{hi['hostname']}:{hi['port']}"
|
||||
self.public_jwks: Dict[str, Dict[str, Any]] = {}
|
||||
self.trusted_users: Dict[IPAddr, Dict[str, Any]] = {}
|
||||
self.oneshot_tokens: Dict[str, OneshotToken] = {}
|
||||
|
||||
# Get allowed cors domains
|
||||
self.cors_domains: List[str] = []
|
||||
for domain in config.getlist('cors_domains', []):
|
||||
bad_match = re.search(r"^.+\.[^:]*\*", domain)
|
||||
if bad_match is not None:
|
||||
self.server.add_warning(
|
||||
f"[authorization]: Unsafe domain '{domain}' in option "
|
||||
f"'cors_domains'. Wildcards are not permitted in the"
|
||||
" top level domain."
|
||||
)
|
||||
continue
|
||||
if domain.endswith("/"):
|
||||
self.server.add_warning(
|
||||
f"[authorization]: Invalid domain '{domain}' in option "
|
||||
"'cors_domains'. Domain's cannot contain a trailing "
|
||||
"slash."
|
||||
)
|
||||
else:
|
||||
self.cors_domains.append(
|
||||
domain.replace(".", "\\.").replace("*", ".*"))
|
||||
|
||||
# Get Trusted Clients
|
||||
self.trusted_ips: List[IPAddr] = []
|
||||
self.trusted_ranges: List[IPNetwork] = []
|
||||
self.trusted_domains: List[str] = []
|
||||
for val in config.getlist('trusted_clients', []):
|
||||
# Check IP address
|
||||
try:
|
||||
tc = ipaddress.ip_address(val)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
self.trusted_ips.append(tc)
|
||||
continue
|
||||
# Check ip network
|
||||
try:
|
||||
tn = ipaddress.ip_network(val)
|
||||
except ValueError as e:
|
||||
if "has host bits set" in str(e):
|
||||
self.server.add_warning(
|
||||
f"[authorization]: Invalid CIDR expression '{val}' "
|
||||
"in option 'trusted_clients'")
|
||||
continue
|
||||
pass
|
||||
else:
|
||||
self.trusted_ranges.append(tn)
|
||||
continue
|
||||
# Check hostname
|
||||
match = re.match(r"([a-z0-9]+(-[a-z0-9]+)*\.?)+[a-z]{2,}$", val)
|
||||
if match is not None:
|
||||
self.trusted_domains.append(val.lower())
|
||||
else:
|
||||
self.server.add_warning(
|
||||
f"[authorization]: Invalid domain name '{val}' "
|
||||
"in option 'trusted_clients'")
|
||||
|
||||
t_clients = "\n".join(
|
||||
[str(ip) for ip in self.trusted_ips] +
|
||||
[str(rng) for rng in self.trusted_ranges] +
|
||||
self.trusted_domains)
|
||||
c_domains = "\n".join(self.cors_domains)
|
||||
|
||||
logging.info(
|
||||
f"Authorization Configuration Loaded\n"
|
||||
f"Trusted Clients:\n{t_clients}\n"
|
||||
f"CORS Domains:\n{c_domains}")
|
||||
|
||||
eventloop = self.server.get_event_loop()
|
||||
self.prune_timer = eventloop.register_timer(
|
||||
self._prune_conn_handler)
|
||||
|
||||
# Register Authorization Endpoints
|
||||
self.server.register_endpoint(
|
||||
"/access/login", RequestType.POST, self._handle_login,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET,
|
||||
auth_required=False
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/logout", RequestType.POST, self._handle_logout,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/refresh_jwt", RequestType.POST, self._handle_refresh_jwt,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET,
|
||||
auth_required=False
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/user", RequestType.all(), self._handle_user_request,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/users/list", RequestType.GET, self._handle_list_request,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/user/password", RequestType.POST, self._handle_password_reset,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
# Custom endpoint: find a user by username and reset password (only suitable for ordinary user)
|
||||
self.server.register_endpoint(
|
||||
"/access/user/password_by_name", RequestType.POST, self._handle_password_reset_by_name,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/api_key", RequestType.GET | RequestType.POST,
|
||||
self._handle_apikey_request,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/oneshot_token", RequestType.GET, self._handle_oneshot_request,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/access/info", RequestType.GET, self._handle_info_request,
|
||||
transports=TransportType.HTTP | TransportType.WEBSOCKET,
|
||||
auth_required=False
|
||||
)
|
||||
wsm: WebsocketManager = self.server.lookup_component("websockets")
|
||||
wsm.register_notification("authorization:user_created")
|
||||
wsm.register_notification(
|
||||
"authorization:user_deleted", event_type="logout"
|
||||
)
|
||||
wsm.register_notification(
|
||||
"authorization:user_logged_out", event_type="logout"
|
||||
)
|
||||
|
||||
async def component_init(self) -> None:
|
||||
# Populate users from database
|
||||
cursor = await self.user_table.execute(f"SELECT * FROM {USER_TABLE}")
|
||||
self.users = {row[0]: UserInfo(**dict(row)) for row in await cursor.fetchall()}
|
||||
need_sync = self._initialize_users()
|
||||
if need_sync:
|
||||
await self._sync_user_table()
|
||||
self.prune_timer.start(delay=PRUNE_CHECK_TIME)
|
||||
|
||||
async def _sync_user(self, username: str) -> None:
|
||||
user = self.users[username]
|
||||
vals = user.as_tuple()
|
||||
placeholders = ",".join("?" * len(vals))
|
||||
async with self.user_table as tx:
|
||||
await tx.execute(
|
||||
f"REPLACE INTO {USER_TABLE} VALUES({placeholders})", vals
|
||||
)
|
||||
|
||||
async def _sync_user_table(self) -> None:
|
||||
async with self.user_table as tx:
|
||||
await tx.execute(f"DELETE FROM {USER_TABLE}")
|
||||
user_vals: List[Tuple[Any, ...]]
|
||||
user_vals = [user.as_tuple() for user in self.users.values()]
|
||||
if not user_vals:
|
||||
return
|
||||
placeholders = ",".join("?" * len(user_vals[0]))
|
||||
await tx.executemany(
|
||||
f"INSERT INTO {USER_TABLE} VALUES({placeholders})", user_vals
|
||||
)
|
||||
|
||||
def _initialize_users(self) -> bool:
|
||||
need_sync = False
|
||||
api_user: Optional[UserInfo] = self.users.get(API_USER, None)
|
||||
if api_user is None:
|
||||
need_sync = True
|
||||
self.users[API_USER] = UserInfo(username=API_USER, password=self.api_key)
|
||||
else:
|
||||
self.api_key = api_user.password
|
||||
for username, user_info in list(self.users.items()):
|
||||
if username == API_USER:
|
||||
continue
|
||||
# generate jwks for valid users
|
||||
if user_info.jwt_secret is not None:
|
||||
try:
|
||||
priv_key = self._load_private_key(user_info.jwt_secret)
|
||||
jwk_id = user_info.jwk_id
|
||||
assert jwk_id is not None
|
||||
except (self.server.error, KeyError, AssertionError):
|
||||
logging.info("Invalid jwk found for user, removing")
|
||||
user_info.jwt_secret = None
|
||||
user_info.jwk_id = None
|
||||
self.users[username] = user_info
|
||||
need_sync = True
|
||||
continue
|
||||
self.public_jwks[jwk_id] = self._generate_public_jwk(priv_key)
|
||||
return need_sync
|
||||
|
||||
async def _handle_apikey_request(self, web_request: WebRequest) -> str:
|
||||
if web_request.get_request_type() == RequestType.POST:
|
||||
self.api_key = uuid.uuid4().hex
|
||||
self.users[API_USER].password = self.api_key
|
||||
await self._sync_user(API_USER)
|
||||
return self.api_key
|
||||
|
||||
async def _handle_oneshot_request(self, web_request: WebRequest) -> str:
|
||||
ip = web_request.get_ip_address()
|
||||
assert ip is not None
|
||||
user_info = web_request.get_current_user()
|
||||
return self.get_oneshot_token(ip, user_info)
|
||||
|
||||
async def _handle_login(self, web_request: WebRequest) -> Dict[str, Any]:
|
||||
ip = web_request.get_ip_address()
|
||||
if ip is not None and self.check_logins_maxed(ip):
|
||||
raise HTTPError(
|
||||
401, "Unauthorized, Maximum Login Attempts Reached"
|
||||
)
|
||||
try:
|
||||
ret = await self._login_jwt_user(web_request)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if ip is not None:
|
||||
failed = self.failed_logins.get(ip, 0)
|
||||
self.failed_logins[ip] = failed + 1
|
||||
raise
|
||||
if ip is not None:
|
||||
self.failed_logins.pop(ip, None)
|
||||
return ret
|
||||
|
||||
async def _handle_logout(self, web_request: WebRequest) -> Dict[str, str]:
|
||||
user_info = web_request.get_current_user()
|
||||
if user_info is None:
|
||||
raise self.server.error("No user logged in")
|
||||
username: str = user_info.username
|
||||
if username in RESERVED_USERS:
|
||||
raise self.server.error(
|
||||
f"Invalid log out request for user {username}")
|
||||
jwk_id: Optional[str] = self.users[username].jwk_id
|
||||
self.users[username].jwt_secret = None
|
||||
self.users[username].jwk_id = None
|
||||
if jwk_id is not None:
|
||||
self.public_jwks.pop(jwk_id, None)
|
||||
await self._sync_user(username)
|
||||
eventloop = self.server.get_event_loop()
|
||||
eventloop.delay_callback(
|
||||
.005, self.server.send_event, "authorization:user_logged_out",
|
||||
{'username': username}
|
||||
)
|
||||
return {
|
||||
"username": username,
|
||||
"action": "user_logged_out"
|
||||
}
|
||||
|
||||
async def _handle_info_request(self, web_request: WebRequest) -> Dict[str, Any]:
|
||||
sources = ["moonraker"]
|
||||
if self.ldap is not None:
|
||||
sources.append("ldap")
|
||||
login_req = self.force_logins and len(self.users) > 1
|
||||
request_trusted: Optional[bool] = None
|
||||
user = web_request.get_current_user()
|
||||
req_ip = web_request.ip_addr
|
||||
if user is not None and user.username == TRUSTED_USER:
|
||||
request_trusted = True
|
||||
elif req_ip is not None:
|
||||
request_trusted = await self._check_authorized_ip(req_ip)
|
||||
return {
|
||||
"default_source": self.default_source,
|
||||
"available_sources": sources,
|
||||
"login_required": login_req,
|
||||
"trusted": request_trusted
|
||||
}
|
||||
|
||||
async def _handle_refresh_jwt(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, str]:
|
||||
refresh_token: str = web_request.get_str('refresh_token')
|
||||
try:
|
||||
user_info = self.decode_jwt(refresh_token, token_type="refresh")
|
||||
except Exception:
|
||||
raise self.server.error("Invalid Refresh Token", 401)
|
||||
username: str = user_info.username
|
||||
if user_info.jwt_secret is None or user_info.jwk_id is None:
|
||||
raise self.server.error("User not logged in", 401)
|
||||
private_key = self._load_private_key(user_info.jwt_secret)
|
||||
jwk_id: str = user_info.jwk_id
|
||||
token = self._generate_jwt(username, jwk_id, private_key)
|
||||
return {
|
||||
'username': username,
|
||||
'token': token,
|
||||
'source': user_info.source,
|
||||
'action': 'user_jwt_refresh'
|
||||
}
|
||||
|
||||
async def _handle_user_request(
|
||||
self, web_request: WebRequest
|
||||
) -> Dict[str, Any]:
|
||||
req_type = web_request.get_request_type()
|
||||
if req_type == RequestType.GET:
|
||||
user = web_request.get_current_user()
|
||||
if user is None:
|
||||
return {
|
||||
"username": None,
|
||||
"source": None,
|
||||
"created_on": None,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"username": user.username,
|
||||
"source": user.source,
|
||||
"created_on": user.created_on
|
||||
}
|
||||
elif req_type == RequestType.POST:
|
||||
# Create User
|
||||
return await self._login_jwt_user(web_request, create=True)
|
||||
elif req_type == RequestType.DELETE:
|
||||
# Delete User
|
||||
return await self._delete_jwt_user(web_request)
|
||||
raise self.server.error("Invalid Request Method")
|
||||
|
||||
async def _handle_list_request(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
user_list = []
|
||||
for user in self.users.values():
|
||||
if user.username == API_USER:
|
||||
continue
|
||||
user_list.append({
|
||||
'username': user.username,
|
||||
'source': user.source,
|
||||
'created_on': user.created_on
|
||||
})
|
||||
return {
|
||||
'users': user_list
|
||||
}
|
||||
|
||||
async def _handle_password_reset(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, str]:
|
||||
password: str = web_request.get_str('password')
|
||||
new_pass: str = web_request.get_str('new_password')
|
||||
user_info = web_request.get_current_user()
|
||||
if user_info is None:
|
||||
raise self.server.error("No Current User")
|
||||
username = user_info.username
|
||||
if user_info.source == "ldap":
|
||||
raise self.server.error(
|
||||
f"Can´t Reset password for ldap user {username}")
|
||||
if username in RESERVED_USERS:
|
||||
raise self.server.error(
|
||||
f"Invalid Reset Request for user {username}")
|
||||
salt = bytes.fromhex(user_info.salt)
|
||||
hashed_pass = hashlib.pbkdf2_hmac(
|
||||
'sha256', password.encode(), salt, HASH_ITER).hex()
|
||||
if hashed_pass != user_info.password:
|
||||
raise self.server.error("Invalid Password")
|
||||
new_hashed_pass = hashlib.pbkdf2_hmac(
|
||||
'sha256', new_pass.encode(), salt, HASH_ITER).hex()
|
||||
self.users[username].password = new_hashed_pass
|
||||
await self._sync_user(username)
|
||||
return {
|
||||
'username': username,
|
||||
'action': "user_password_reset"
|
||||
}
|
||||
|
||||
async def _handle_password_reset_by_name(self,
|
||||
web_request: WebRequest
|
||||
) -> Dict[str, str]:
|
||||
username: str = web_request.get_str('username')
|
||||
new_pass: str = web_request.get_str('new_password')
|
||||
|
||||
user_info = self.users[username]
|
||||
if user_info.source == "ldap":
|
||||
raise self.server.error(
|
||||
f"Can´t Reset password for ldap user {username}")
|
||||
if username in RESERVED_USERS:
|
||||
raise self.server.error(
|
||||
f"Invalid Reset Request for user {username}")
|
||||
salt = bytes.fromhex(user_info.salt)
|
||||
new_hashed_pass = hashlib.pbkdf2_hmac(
|
||||
'sha256', new_pass.encode(), salt, HASH_ITER).hex()
|
||||
self.users[username].password = new_hashed_pass
|
||||
await self._sync_user(username)
|
||||
return {
|
||||
'username': username,
|
||||
'action': "user_password_reset_by_name"
|
||||
}
|
||||
|
||||
async def _login_jwt_user(
|
||||
self, web_request: WebRequest, create: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
username: str = web_request.get_str('username')
|
||||
password: str = web_request.get_str('password')
|
||||
source: str = web_request.get_str(
|
||||
'source', self.default_source
|
||||
).lower()
|
||||
if source not in AUTH_SOURCES:
|
||||
raise self.server.error(f"Invalid 'source': {source}")
|
||||
user_info: UserInfo
|
||||
if username in RESERVED_USERS:
|
||||
raise self.server.error(
|
||||
f"Invalid Request for user {username}")
|
||||
if source == "ldap":
|
||||
if create:
|
||||
raise self.server.error("Cannot Create LDAP User")
|
||||
if self.ldap is None:
|
||||
raise self.server.error(
|
||||
"LDAP authentication not available", 401
|
||||
)
|
||||
await self.ldap.authenticate_ldap_user(username, password)
|
||||
if username not in self.users:
|
||||
create = True
|
||||
if create:
|
||||
if username in self.users:
|
||||
raise self.server.error(f"User {username} already exists")
|
||||
salt = secrets.token_bytes(32)
|
||||
hashed_pass = hashlib.pbkdf2_hmac(
|
||||
'sha256', password.encode(), salt, HASH_ITER).hex()
|
||||
user_info = UserInfo(
|
||||
username=username,
|
||||
password=hashed_pass,
|
||||
salt=salt.hex(),
|
||||
source=source,
|
||||
)
|
||||
self.users[username] = user_info
|
||||
await self._sync_user(username)
|
||||
action = "user_created"
|
||||
if source == "ldap":
|
||||
# Dont notify user created
|
||||
action = "user_logged_in"
|
||||
create = False
|
||||
else:
|
||||
if username not in self.users:
|
||||
raise self.server.error(f"Unregistered User: {username}")
|
||||
user_info = self.users[username]
|
||||
auth_src = user_info.source
|
||||
if auth_src != source:
|
||||
raise self.server.error(
|
||||
f"Moonraker cannot authenticate user '{username}', must "
|
||||
f"specify source '{auth_src}'", 401
|
||||
)
|
||||
salt = bytes.fromhex(user_info.salt)
|
||||
hashed_pass = hashlib.pbkdf2_hmac(
|
||||
'sha256', password.encode(), salt, HASH_ITER).hex()
|
||||
action = "user_logged_in"
|
||||
if hashed_pass != user_info.password:
|
||||
raise self.server.error("Invalid Password")
|
||||
jwt_secret_hex: Optional[str] = user_info.jwt_secret
|
||||
if jwt_secret_hex is None:
|
||||
private_key = Signer()
|
||||
jwk_id = base64url_encode(secrets.token_bytes()).decode()
|
||||
user_info.jwt_secret = private_key.hex_seed().decode() # type: ignore
|
||||
user_info.jwk_id = jwk_id
|
||||
self.users[username] = user_info
|
||||
await self._sync_user(username)
|
||||
self.public_jwks[jwk_id] = self._generate_public_jwk(private_key)
|
||||
else:
|
||||
private_key = self._load_private_key(jwt_secret_hex)
|
||||
if user_info.jwk_id is None:
|
||||
user_info.jwk_id = base64url_encode(secrets.token_bytes()).decode()
|
||||
jwk_id = user_info.jwk_id
|
||||
token = self._generate_jwt(username, jwk_id, private_key)
|
||||
refresh_token = self._generate_jwt(
|
||||
username, jwk_id, private_key, token_type="refresh",
|
||||
exp_time=datetime.timedelta(days=self.login_timeout))
|
||||
conn = web_request.get_client_connection()
|
||||
if create:
|
||||
event_loop = self.server.get_event_loop()
|
||||
event_loop.delay_callback(
|
||||
.005, self.server.send_event,
|
||||
"authorization:user_created",
|
||||
{'username': username})
|
||||
elif conn is not None:
|
||||
conn.user_info = user_info
|
||||
return {
|
||||
'username': username,
|
||||
'token': token,
|
||||
'source': user_info.source,
|
||||
'refresh_token': refresh_token,
|
||||
'action': action
|
||||
}
|
||||
|
||||
async def _delete_jwt_user(self, web_request: WebRequest) -> Dict[str, str]:
|
||||
username: str = web_request.get_str('username')
|
||||
current_user = web_request.get_current_user()
|
||||
if current_user is not None:
|
||||
curname = current_user.username
|
||||
if curname == username:
|
||||
raise self.server.error(f"Cannot delete logged in user {curname}")
|
||||
if username in RESERVED_USERS:
|
||||
raise self.server.error(
|
||||
f"Invalid Request for reserved user {username}")
|
||||
user_info: Optional[UserInfo] = self.users.get(username)
|
||||
if user_info is None:
|
||||
raise self.server.error(f"No registered user: {username}")
|
||||
if user_info.jwk_id is not None:
|
||||
self.public_jwks.pop(user_info.jwk_id, None)
|
||||
del self.users[username]
|
||||
async with self.user_table as tx:
|
||||
await tx.execute(
|
||||
f"DELETE FROM {USER_TABLE} WHERE username = ?", (username,)
|
||||
)
|
||||
event_loop = self.server.get_event_loop()
|
||||
event_loop.delay_callback(
|
||||
.005, self.server.send_event,
|
||||
"authorization:user_deleted",
|
||||
{'username': username})
|
||||
return {
|
||||
"username": username,
|
||||
"action": "user_deleted"
|
||||
}
|
||||
|
||||
def _generate_jwt(self,
|
||||
username: str,
|
||||
jwk_id: str,
|
||||
private_key: Signer,
|
||||
token_type: str = "access",
|
||||
exp_time: datetime.timedelta = JWT_EXP_TIME
|
||||
) -> str:
|
||||
curtime = int(time.time())
|
||||
payload = {
|
||||
'iss': self.issuer,
|
||||
'aud': "Moonraker",
|
||||
'iat': curtime,
|
||||
'exp': curtime + int(exp_time.total_seconds()),
|
||||
'username': username,
|
||||
'token_type': token_type
|
||||
}
|
||||
header = {'kid': jwk_id}
|
||||
header.update(JWT_HEADER)
|
||||
jwt_header = base64url_encode(jsonw.dumps(header))
|
||||
jwt_payload = base64url_encode(jsonw.dumps(payload))
|
||||
jwt_msg = b".".join([jwt_header, jwt_payload])
|
||||
sig = private_key.signature(jwt_msg)
|
||||
jwt_sig = base64url_encode(sig)
|
||||
return b".".join([jwt_msg, jwt_sig]).decode()
|
||||
|
||||
def decode_jwt(
|
||||
self, token: str, token_type: str = "access", check_exp: bool = True
|
||||
) -> UserInfo:
|
||||
message, sig = token.rsplit('.', maxsplit=1)
|
||||
enc_header, enc_payload = message.split('.')
|
||||
header: Dict[str, Any] = jsonw.loads(base64url_decode(enc_header))
|
||||
sig_bytes = base64url_decode(sig)
|
||||
|
||||
# verify header
|
||||
if header.get('typ') != "JWT" or header.get('alg') != "EdDSA":
|
||||
raise self.server.error("Invalid JWT header")
|
||||
jwk_id: Optional[str] = header.get('kid')
|
||||
if jwk_id not in self.public_jwks:
|
||||
raise self.server.error("Invalid key ID")
|
||||
|
||||
# validate signature
|
||||
public_key = self._public_key_from_jwk(self.public_jwks[jwk_id])
|
||||
public_key.verify(sig_bytes + message.encode())
|
||||
|
||||
# validate claims
|
||||
payload: Dict[str, Any] = jsonw.loads(base64url_decode(enc_payload))
|
||||
if payload['token_type'] != token_type:
|
||||
raise self.server.error(
|
||||
f"JWT Token type mismatch: Expected {token_type}, "
|
||||
f"Recd: {payload['token_type']}", 401)
|
||||
if payload['iss'] != self.issuer:
|
||||
raise self.server.error("Invalid JWT Issuer", 401)
|
||||
if payload['aud'] != "Moonraker":
|
||||
raise self.server.error("Invalid JWT Audience", 401)
|
||||
if check_exp and payload['exp'] < int(time.time()):
|
||||
raise self.server.error("JWT Expired", 401)
|
||||
|
||||
# get user
|
||||
user_info: Optional[UserInfo] = self.users.get(
|
||||
payload.get('username', ""), None)
|
||||
if user_info is None:
|
||||
raise self.server.error("Unknown user", 401)
|
||||
return user_info
|
||||
|
||||
def validate_jwt(self, token: str) -> UserInfo:
|
||||
try:
|
||||
user_info = self.decode_jwt(token)
|
||||
except Exception as e:
|
||||
if isinstance(e, self.server.error):
|
||||
raise
|
||||
raise self.server.error(
|
||||
f"Failed to decode JWT: {e}", 401
|
||||
) from e
|
||||
return user_info
|
||||
|
||||
def validate_api_key(self, api_key: str) -> UserInfo:
|
||||
if not self.enable_api_key:
|
||||
raise self.server.error("API Key authentication is disabled", 401)
|
||||
if api_key and api_key == self.api_key:
|
||||
return self.users[API_USER]
|
||||
raise self.server.error("Invalid API Key", 401)
|
||||
|
||||
def _load_private_key(self, secret: str) -> Signer:
|
||||
try:
|
||||
key = Signer(bytes.fromhex(secret))
|
||||
except Exception:
|
||||
raise self.server.error(
|
||||
"Error decoding private key, user data may"
|
||||
" be corrupt", 500) from None
|
||||
return key
|
||||
|
||||
def _generate_public_jwk(self, private_key: Signer) -> Dict[str, Any]:
|
||||
public_key = private_key.vk
|
||||
return {
|
||||
'x': base64url_encode(public_key).decode(),
|
||||
'kty': "OKP",
|
||||
'crv': "Ed25519",
|
||||
'use': "sig"
|
||||
}
|
||||
|
||||
def _public_key_from_jwk(self, jwk: Dict[str, Any]) -> Verifier:
|
||||
if jwk.get('kty') != "OKP":
|
||||
raise self.server.error("Not an Octet Key Pair")
|
||||
if jwk.get('crv') != "Ed25519":
|
||||
raise self.server.error("Invalid Curve")
|
||||
if 'x' not in jwk:
|
||||
raise self.server.error("No 'x' argument in jwk")
|
||||
key = base64url_decode(jwk['x'])
|
||||
return Verifier(key.hex().encode())
|
||||
|
||||
def _prune_conn_handler(self, eventtime: float) -> float:
|
||||
cur_time = time.time()
|
||||
for ip, user_info in list(self.trusted_users.items()):
|
||||
exp_time: float = user_info['expires_at']
|
||||
if cur_time >= exp_time:
|
||||
self.trusted_users.pop(ip, None)
|
||||
logging.info(f"Trusted Connection Expired, IP: {ip}")
|
||||
for ip, fqdn_info in list(self.fqdn_cache.items()):
|
||||
exp_time = fqdn_info["expires_at"]
|
||||
if cur_time >= exp_time:
|
||||
domain: str = fqdn_info["domain"]
|
||||
self.fqdn_cache.pop(ip, None)
|
||||
logging.info(f"Cached FQDN Expired, IP: {ip}, domain: {domain}")
|
||||
return eventtime + PRUNE_CHECK_TIME
|
||||
|
||||
def _oneshot_token_expire_handler(self, token):
|
||||
self.oneshot_tokens.pop(token, None)
|
||||
|
||||
def get_oneshot_token(self, ip_addr: IPAddr, user: Optional[UserInfo]) -> str:
|
||||
token = base64.b32encode(os.urandom(20)).decode()
|
||||
event_loop = self.server.get_event_loop()
|
||||
hdl = event_loop.delay_callback(
|
||||
ONESHOT_TIMEOUT, self._oneshot_token_expire_handler, token)
|
||||
self.oneshot_tokens[token] = (ip_addr, user, hdl)
|
||||
return token
|
||||
|
||||
def _check_json_web_token(
|
||||
self, request: HTTPServerRequest, required: bool = True
|
||||
) -> Optional[UserInfo]:
|
||||
auth_token: Optional[str] = request.headers.get("Authorization")
|
||||
if auth_token is None:
|
||||
auth_token = request.headers.get("X-Access-Token")
|
||||
if auth_token is None:
|
||||
qtoken = request.query_arguments.get('access_token', None)
|
||||
if qtoken is not None:
|
||||
auth_token = qtoken[-1].decode(errors="ignore")
|
||||
elif auth_token.startswith("Bearer "):
|
||||
auth_token = auth_token[7:]
|
||||
else:
|
||||
return None
|
||||
if auth_token:
|
||||
try:
|
||||
return self.decode_jwt(auth_token, check_exp=required)
|
||||
except Exception:
|
||||
logging.exception(f"JWT Decode Error {auth_token}")
|
||||
raise HTTPError(401, "JWT Decode Error")
|
||||
return None
|
||||
|
||||
async def _check_authorized_ip(self, ip: IPAddr) -> bool:
|
||||
if ip in self.trusted_ips:
|
||||
return True
|
||||
for rng in self.trusted_ranges:
|
||||
if ip in rng:
|
||||
return True
|
||||
if self.trusted_domains:
|
||||
if ip in self.fqdn_cache:
|
||||
fqdn: str = self.fqdn_cache[ip]["domain"]
|
||||
else:
|
||||
eventloop = self.server.get_event_loop()
|
||||
try:
|
||||
fut = eventloop.run_in_thread(socket.getfqdn, str(ip))
|
||||
fqdn = await asyncio.wait_for(fut, 5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logging.info("Call to socket.getfqdn() timed out")
|
||||
return False
|
||||
else:
|
||||
fqdn = fqdn.lower()
|
||||
self.fqdn_cache[ip] = {
|
||||
"expires_at": time.time() + FQDN_CACHE_TIMEOUT,
|
||||
"domain": fqdn
|
||||
}
|
||||
return fqdn in self.trusted_domains
|
||||
return False
|
||||
|
||||
async def _check_trusted_connection(
|
||||
self, ip: Optional[IPAddr]
|
||||
) -> Optional[UserInfo]:
|
||||
if ip is not None:
|
||||
curtime = time.time()
|
||||
exp_time = curtime + TRUSTED_CONNECTION_TIMEOUT
|
||||
if ip in self.trusted_users:
|
||||
self.trusted_users[ip]["expires_at"] = exp_time
|
||||
return self.trusted_users[ip]["user"]
|
||||
elif await self._check_authorized_ip(ip):
|
||||
logging.info(
|
||||
f"Trusted Connection Detected, IP: {ip}")
|
||||
self.trusted_users[ip] = {
|
||||
"user": UserInfo(TRUSTED_USER, "", curtime),
|
||||
"expires_at": exp_time
|
||||
}
|
||||
return self.trusted_users[ip]["user"]
|
||||
return None
|
||||
|
||||
def _check_oneshot_token(
|
||||
self, token: str, cur_ip: Optional[IPAddr]
|
||||
) -> Optional[UserInfo]:
|
||||
if token in self.oneshot_tokens:
|
||||
ip_addr, user, hdl = self.oneshot_tokens.pop(token)
|
||||
hdl.cancel()
|
||||
if cur_ip != ip_addr:
|
||||
logging.info(f"Oneshot Token IP Mismatch: expected{ip_addr}"
|
||||
f", Recd: {cur_ip}")
|
||||
return None
|
||||
return user
|
||||
else:
|
||||
return None
|
||||
|
||||
def check_logins_maxed(self, ip_addr: IPAddr) -> bool:
|
||||
if self.max_logins is None:
|
||||
return False
|
||||
return self.failed_logins.get(ip_addr, 0) >= self.max_logins
|
||||
|
||||
async def authenticate_request(
|
||||
self, request: HTTPServerRequest, auth_required: bool = True
|
||||
) -> Optional[UserInfo]:
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
|
||||
# Allow local request
|
||||
try:
|
||||
# logging.info(f"request.remote_ip: {request.remote_ip}, is_loopback: {ipaddress.ip_address(request.remote_ip).is_loopback}") # type: ignore
|
||||
ip = ipaddress.ip_address(request.remote_ip) # type: ignore
|
||||
if ip.is_loopback:
|
||||
return None
|
||||
except ValueError:
|
||||
logging.exception(
|
||||
f"Unable to Create IP Address {request.remote_ip}")
|
||||
ip = None
|
||||
|
||||
# Check JSON Web Token
|
||||
jwt_user = self._check_json_web_token(request, auth_required)
|
||||
if jwt_user is not None:
|
||||
return jwt_user
|
||||
|
||||
# Check oneshot access token
|
||||
ost: Optional[List[bytes]] = request.arguments.get('token', None)
|
||||
if ost is not None:
|
||||
ost_user = self._check_oneshot_token(ost[-1].decode(), ip)
|
||||
if ost_user is not None:
|
||||
return ost_user
|
||||
|
||||
# Check API Key Header
|
||||
if self.enable_api_key:
|
||||
key: Optional[str] = request.headers.get("X-Api-Key")
|
||||
if key and key == self.api_key:
|
||||
return self.users[API_USER]
|
||||
|
||||
# If the force_logins option is enabled and at least one user is created
|
||||
# then trusted user authentication is disabled
|
||||
if self.force_logins and len(self.users) > 1:
|
||||
if not auth_required:
|
||||
return None
|
||||
raise HTTPError(401, "Unauthorized, Force Logins Enabled")
|
||||
|
||||
# Check if IP is trusted. If this endpoint doesn't require authentication
|
||||
# then it is acceptable to return None
|
||||
trusted_user = await self._check_trusted_connection(ip)
|
||||
if trusted_user is not None:
|
||||
return trusted_user
|
||||
if not auth_required:
|
||||
return None
|
||||
|
||||
raise HTTPError(401, "Unauthorized")
|
||||
|
||||
async def check_cors(self, origin: Optional[str]) -> bool:
|
||||
if origin is None or not self.cors_domains:
|
||||
return False
|
||||
for regex in self.cors_domains:
|
||||
match = re.match(regex, origin)
|
||||
if match is not None:
|
||||
if match.group() == origin:
|
||||
logging.debug(f"CORS Pattern Matched, origin: {origin} "
|
||||
f" | pattern: {regex}")
|
||||
return True
|
||||
else:
|
||||
logging.debug(f"Partial Cors Match: {match.group()}")
|
||||
else:
|
||||
# Check to see if the origin contains an IP that matches a
|
||||
# current trusted connection
|
||||
match = re.search(r"^https?://([^/:]+)", origin)
|
||||
if match is not None:
|
||||
ip = match.group(1)
|
||||
try:
|
||||
ipaddr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if await self._check_authorized_ip(ipaddr):
|
||||
logging.debug(f"Cors request matched trusted IP: {ip}")
|
||||
return True
|
||||
logging.debug(f"No CORS match for origin: {origin}\n"
|
||||
f"Patterns: {self.cors_domains}")
|
||||
return False
|
||||
|
||||
def cors_enabled(self) -> bool:
|
||||
return self.cors_domains is not None
|
||||
|
||||
def close(self) -> None:
|
||||
self.prune_timer.stop()
|
||||
|
||||
|
||||
def load_component(config: ConfigHelper) -> Authorization:
|
||||
return Authorization(config)
|
||||
1178
moonraker/file_manager/metadata.py
Normal file
1178
moonraker/file_manager/metadata.py
Normal file
File diff suppressed because it is too large
Load Diff
354
moonraker/klippy_apis.py
Normal file
354
moonraker/klippy_apis.py
Normal file
@@ -0,0 +1,354 @@
|
||||
# Helper for Moonraker to Klippy API calls.
|
||||
#
|
||||
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
|
||||
#
|
||||
# This file may be distributed under the terms of the GNU GPLv3 license.
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from ..utils import Sentinel
|
||||
from ..common import WebRequest, APITransport, RequestType
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
|
||||
# Annotation imports
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Union,
|
||||
Optional,
|
||||
Dict,
|
||||
List,
|
||||
TypeVar,
|
||||
Mapping,
|
||||
Callable,
|
||||
Coroutine
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from ..confighelper import ConfigHelper
|
||||
from ..common import UserInfo
|
||||
from .klippy_connection import KlippyConnection as Klippy
|
||||
from .file_manager.file_manager import FileManager
|
||||
Subscription = Dict[str, Optional[List[Any]]]
|
||||
SubCallback = Callable[[Dict[str, Dict[str, Any]], float], Optional[Coroutine]]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
INFO_ENDPOINT = "info"
|
||||
ESTOP_ENDPOINT = "emergency_stop"
|
||||
LIST_EPS_ENDPOINT = "list_endpoints"
|
||||
GC_OUTPUT_ENDPOINT = "gcode/subscribe_output"
|
||||
GCODE_ENDPOINT = "gcode/script"
|
||||
SUBSCRIPTION_ENDPOINT = "objects/subscribe"
|
||||
STATUS_ENDPOINT = "objects/query"
|
||||
OBJ_LIST_ENDPOINT = "objects/list"
|
||||
REG_METHOD_ENDPOINT = "register_remote_method"
|
||||
|
||||
class KlippyAPI(APITransport):
|
||||
def __init__(self, config: ConfigHelper) -> None:
|
||||
self.server = config.get_server()
|
||||
self.klippy: Klippy = self.server.lookup_component("klippy_connection")
|
||||
self.fm: FileManager = self.server.lookup_component("file_manager")
|
||||
self.eventloop = self.server.get_event_loop()
|
||||
app_args = self.server.get_app_args()
|
||||
self.version = app_args.get('software_version')
|
||||
# Maintain a subscription for all moonraker requests, as
|
||||
# we do not want to overwrite them
|
||||
self.host_subscription: Subscription = {}
|
||||
self.subscription_callbacks: List[SubCallback] = []
|
||||
|
||||
# Register GCode Aliases
|
||||
self.server.register_endpoint(
|
||||
"/printer/print/pause", RequestType.POST, self._gcode_pause
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/print/resume", RequestType.POST, self._gcode_resume
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/print/cancel", RequestType.POST, self._gcode_cancel
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/print/start", RequestType.POST, self._gcode_start_print
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/restart", RequestType.POST, self._gcode_restart
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/firmware_restart", RequestType.POST, self._gcode_firmware_restart
|
||||
)
|
||||
self.server.register_event_handler(
|
||||
"server:klippy_disconnect", self._on_klippy_disconnect
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/list_endpoints", RequestType.GET, self.list_endpoints
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/breakheater", RequestType.POST, self.breakheater
|
||||
)
|
||||
self.server.register_endpoint(
|
||||
"/printer/breakmacro", RequestType.POST, self.breakmacro
|
||||
)
|
||||
|
||||
def _on_klippy_disconnect(self) -> None:
|
||||
self.host_subscription.clear()
|
||||
self.subscription_callbacks.clear()
|
||||
|
||||
async def _gcode_pause(self, web_request: WebRequest) -> str:
|
||||
return await self.pause_print()
|
||||
|
||||
async def _gcode_resume(self, web_request: WebRequest) -> str:
|
||||
return await self.resume_print()
|
||||
|
||||
async def _gcode_cancel(self, web_request: WebRequest) -> str:
|
||||
return await self.cancel_print()
|
||||
|
||||
async def _gcode_start_print(self, web_request: WebRequest) -> str:
|
||||
filename: str = web_request.get_str('filename')
|
||||
user = web_request.get_current_user()
|
||||
return await self.start_print(filename, user=user)
|
||||
|
||||
async def _gcode_restart(self, web_request: WebRequest) -> str:
|
||||
return await self.do_restart("RESTART")
|
||||
|
||||
async def _gcode_firmware_restart(self, web_request: WebRequest) -> str:
|
||||
return await self.do_restart("FIRMWARE_RESTART")
|
||||
|
||||
async def _send_klippy_request(
|
||||
self,
|
||||
method: str,
|
||||
params: Dict[str, Any],
|
||||
default: Any = Sentinel.MISSING,
|
||||
transport: Optional[APITransport] = None
|
||||
) -> Any:
|
||||
try:
|
||||
req = WebRequest(method, params, transport=transport or self)
|
||||
result = await self.klippy.request(req)
|
||||
except self.server.error:
|
||||
if default is Sentinel.MISSING:
|
||||
raise
|
||||
result = default
|
||||
return result
|
||||
|
||||
async def run_gcode(self,
|
||||
script: str,
|
||||
default: Any = Sentinel.MISSING
|
||||
) -> str:
|
||||
params = {'script': script}
|
||||
result = await self._send_klippy_request(
|
||||
GCODE_ENDPOINT, params, default)
|
||||
return result
|
||||
|
||||
async def start_print(
|
||||
self,
|
||||
filename: str,
|
||||
wait_klippy_started: bool = False,
|
||||
user: Optional[UserInfo] = None
|
||||
) -> str:
|
||||
# WARNING: Do not call this method from within the following
|
||||
# event handlers when "wait_klippy_started" is set to True:
|
||||
# klippy_identified, klippy_started, klippy_ready, klippy_disconnect
|
||||
# Doing so will result in "wait_started" blocking for the specifed
|
||||
# timeout (default 20s) and returning False.
|
||||
# XXX - validate that file is on disk
|
||||
if filename[0] == '/':
|
||||
filename = filename[1:]
|
||||
# Escape existing double quotes in the file name
|
||||
filename = filename.replace("\"", "\\\"")
|
||||
homedir = os.path.expanduser("~")
|
||||
if os.path.split(filename)[0].split(os.path.sep)[0] != ".cache":
|
||||
base_path = os.path.join(homedir, "printer_data/gcodes")
|
||||
target = os.path.join(".cache", os.path.basename(filename))
|
||||
cache_path = os.path.join(base_path, ".cache")
|
||||
if not os.path.exists(cache_path):
|
||||
os.makedirs(cache_path)
|
||||
shutil.rmtree(cache_path)
|
||||
os.makedirs(cache_path)
|
||||
metadata = self.fm.gcode_metadata.metadata.get(filename, None)
|
||||
self.copy_file_to_cache(os.path.join(base_path, filename), os.path.join(base_path, target))
|
||||
msg = "// metadata=" + json.dumps(metadata)
|
||||
self.server.send_event("server:gcode_response", msg)
|
||||
filename = target
|
||||
script = f'SDCARD_PRINT_FILE FILENAME="{filename}"'
|
||||
if wait_klippy_started:
|
||||
await self.klippy.wait_started()
|
||||
logging.info(f"Requesting Job Start, filename = {filename}")
|
||||
ret = await self.run_gcode(script)
|
||||
self.server.send_event("klippy_apis:job_start_complete", user)
|
||||
return ret
|
||||
|
||||
async def pause_print(
|
||||
self, default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, str]:
|
||||
self.server.send_event("klippy_apis:pause_requested")
|
||||
logging.info("Requesting job pause...")
|
||||
return await self._send_klippy_request(
|
||||
"pause_resume/pause", {}, default)
|
||||
|
||||
async def resume_print(
|
||||
self, default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, str]:
|
||||
self.server.send_event("klippy_apis:resume_requested")
|
||||
logging.info("Requesting job resume...")
|
||||
return await self._send_klippy_request(
|
||||
"pause_resume/resume", {}, default)
|
||||
|
||||
async def cancel_print(
|
||||
self, default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, str]:
|
||||
self.server.send_event("klippy_apis:cancel_requested")
|
||||
logging.info("Requesting job cancel...")
|
||||
await self._send_klippy_request(
|
||||
"breakmacro", {}, default)
|
||||
await self._send_klippy_request(
|
||||
"breakheater", {}, default)
|
||||
return await self._send_klippy_request(
|
||||
"pause_resume/cancel", {}, default)
|
||||
|
||||
async def breakheater(
|
||||
self, default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, str]:
|
||||
return await self._send_klippy_request(
|
||||
"breakheater", {}, default)
|
||||
|
||||
async def breakmacro(
|
||||
self, default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, str]:
|
||||
return await self._send_klippy_request(
|
||||
"breakmacro", {}, default)
|
||||
|
||||
async def do_restart(
|
||||
self, gc: str, wait_klippy_started: bool = False
|
||||
) -> str:
|
||||
# WARNING: Do not call this method from within the following
|
||||
# event handlers when "wait_klippy_started" is set to True:
|
||||
# klippy_identified, klippy_started, klippy_ready, klippy_disconnect
|
||||
# Doing so will result in "wait_started" blocking for the specifed
|
||||
# timeout (default 20s) and returning False.
|
||||
if wait_klippy_started:
|
||||
await self.klippy.wait_started()
|
||||
try:
|
||||
result = await self.run_gcode(gc)
|
||||
except self.server.error as e:
|
||||
if str(e) == "Klippy Disconnected":
|
||||
result = "ok"
|
||||
else:
|
||||
raise
|
||||
return result
|
||||
|
||||
async def list_endpoints(self,
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, Dict[str, List[str]]]:
|
||||
return await self._send_klippy_request(
|
||||
LIST_EPS_ENDPOINT, {}, default)
|
||||
|
||||
async def emergency_stop(self) -> str:
|
||||
return await self._send_klippy_request(ESTOP_ENDPOINT, {})
|
||||
|
||||
async def get_klippy_info(self,
|
||||
send_id: bool = False,
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, Dict[str, Any]]:
|
||||
params = {}
|
||||
if send_id:
|
||||
ver = self.version
|
||||
params = {'client_info': {'program': "Moonraker", 'version': ver}}
|
||||
return await self._send_klippy_request(INFO_ENDPOINT, params, default)
|
||||
|
||||
async def get_object_list(self,
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, List[str]]:
|
||||
result = await self._send_klippy_request(
|
||||
OBJ_LIST_ENDPOINT, {}, default)
|
||||
if isinstance(result, dict) and 'objects' in result:
|
||||
return result['objects']
|
||||
if default is not Sentinel.MISSING:
|
||||
return default
|
||||
raise self.server.error("Invalid response received from Klippy", 500)
|
||||
|
||||
async def query_objects(self,
|
||||
objects: Mapping[str, Optional[List[str]]],
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, Dict[str, Any]]:
|
||||
params = {'objects': objects}
|
||||
result = await self._send_klippy_request(
|
||||
STATUS_ENDPOINT, params, default)
|
||||
if isinstance(result, dict) and "status" in result:
|
||||
return result["status"]
|
||||
if default is not Sentinel.MISSING:
|
||||
return default
|
||||
raise self.server.error("Invalid response received from Klippy", 500)
|
||||
|
||||
async def subscribe_objects(
|
||||
self,
|
||||
objects: Mapping[str, Optional[List[str]]],
|
||||
callback: Optional[SubCallback] = None,
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING
|
||||
) -> Union[_T, Dict[str, Any]]:
|
||||
# The host transport shares subscriptions amongst all components
|
||||
for obj, items in objects.items():
|
||||
if obj in self.host_subscription:
|
||||
prev = self.host_subscription[obj]
|
||||
if items is None or prev is None:
|
||||
self.host_subscription[obj] = None
|
||||
else:
|
||||
uitems = list(set(prev) | set(items))
|
||||
self.host_subscription[obj] = uitems
|
||||
else:
|
||||
self.host_subscription[obj] = items
|
||||
params = {"objects": dict(self.host_subscription)}
|
||||
result = await self._send_klippy_request(SUBSCRIPTION_ENDPOINT, params, default)
|
||||
if isinstance(result, dict) and "status" in result:
|
||||
if callback is not None:
|
||||
self.subscription_callbacks.append(callback)
|
||||
return result["status"]
|
||||
if default is not Sentinel.MISSING:
|
||||
return default
|
||||
raise self.server.error("Invalid response received from Klippy", 500)
|
||||
|
||||
async def subscribe_from_transport(
|
||||
self,
|
||||
objects: Mapping[str, Optional[List[str]]],
|
||||
transport: APITransport,
|
||||
default: Union[Sentinel, _T] = Sentinel.MISSING,
|
||||
) -> Union[_T, Dict[str, Any]]:
|
||||
params = {"objects": dict(objects)}
|
||||
result = await self._send_klippy_request(
|
||||
SUBSCRIPTION_ENDPOINT, params, default, transport
|
||||
)
|
||||
if isinstance(result, dict) and "status" in result:
|
||||
return result["status"]
|
||||
if default is not Sentinel.MISSING:
|
||||
return default
|
||||
raise self.server.error("Invalid response received from Klippy", 500)
|
||||
|
||||
async def subscribe_gcode_output(self) -> str:
|
||||
template = {'response_template':
|
||||
{'method': "process_gcode_response"}}
|
||||
return await self._send_klippy_request(GC_OUTPUT_ENDPOINT, template)
|
||||
|
||||
async def register_method(self, method_name: str) -> str:
|
||||
return await self._send_klippy_request(
|
||||
REG_METHOD_ENDPOINT,
|
||||
{'response_template': {"method": method_name},
|
||||
'remote_method': method_name})
|
||||
|
||||
def send_status(
|
||||
self, status: Dict[str, Any], eventtime: float
|
||||
) -> None:
|
||||
for cb in self.subscription_callbacks:
|
||||
self.eventloop.register_callback(cb, status, eventtime)
|
||||
self.server.send_event("server:status_update", status)
|
||||
|
||||
def copy_file_to_cache(self, origin, target):
|
||||
stat = os.statvfs("/")
|
||||
free_space = stat.f_frsize * stat.f_bfree
|
||||
filesize = os.path.getsize(os.path.join(origin))
|
||||
if (filesize < free_space):
|
||||
shutil.copy(origin, target)
|
||||
else:
|
||||
msg = "!! Insufficient disk space, unable to read the file."
|
||||
self.server.send_event("server:gcode_response", msg)
|
||||
raise self.server.error("Insufficient disk space, unable to read the file.", 500)
|
||||
|
||||
def load_component(config: ConfigHelper) -> KlippyAPI:
|
||||
return KlippyAPI(config)
|
||||
2184
moonraker/machine.py
Normal file
2184
moonraker/machine.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user