summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--configs/nginx/kvmd.ctx-server.conf9
-rw-r--r--kvmd/apps/kvmd/api/msd.py12
-rw-r--r--kvmd/htserver.py17
-rw-r--r--kvmd/plugins/msd/__init__.py43
-rw-r--r--kvmd/plugins/msd/disabled.py9
-rw-r--r--kvmd/plugins/msd/otg/__init__.py34
-rw-r--r--kvmd/plugins/msd/relay/__init__.py11
-rw-r--r--web/kvm/index.html3
-rw-r--r--web/kvm/navbar-msd.pug1
-rw-r--r--web/share/js/kvm/msd.js8
10 files changed, 145 insertions, 2 deletions
diff --git a/configs/nginx/kvmd.ctx-server.conf b/configs/nginx/kvmd.ctx-server.conf
index 537ed68f..c82e5395 100644
--- a/configs/nginx/kvmd.ctx-server.conf
+++ b/configs/nginx/kvmd.ctx-server.conf
@@ -59,6 +59,15 @@ location /api/ws {
auth_request off;
}
+location /api/msd/read {
+ rewrite ^/api/msd/read$ /msd/read break;
+ rewrite ^/api/msd/read\?(.*)$ /msd/read?$1 break;
+ proxy_pass http://kvmd;
+ include /etc/kvmd/nginx/loc-proxy.conf;
+ proxy_read_timeout 7d;
+ auth_request off;
+}
+
location /api/msd/write_remote {
rewrite ^/api/msd/write_remote$ /msd/write_remote break;
rewrite ^/api/msd/write_remote\?(.*)$ /msd/write_remote?$1 break;
diff --git a/kvmd/apps/kvmd/api/msd.py b/kvmd/apps/kvmd/api/msd.py
index 7347bd8c..e9e1e68b 100644
--- a/kvmd/apps/kvmd/api/msd.py
+++ b/kvmd/apps/kvmd/api/msd.py
@@ -84,6 +84,18 @@ class MsdApi:
# =====
+ @exposed_http("GET", "/msd/read")
+ async def __read_handler(self, request: Request) -> StreamResponse:
+ name = valid_msd_image_name(request.query.get("image"))
+ async with self.__msd.read_image(name) as size:
+ response = await start_streaming(request, "application/octet-stream", size, name)
+ while True:
+ chunk = await self.__msd.read_image_chunk()
+ if not chunk:
+ return response
+ await response.write(chunk)
+ return response
+
@exposed_http("POST", "/msd/write")
async def __write_handler(self, request: Request) -> Response:
name = valid_msd_image_name(request.query.get("image"))
diff --git a/kvmd/htserver.py b/kvmd/htserver.py
index af4498c1..35f4b388 100644
--- a/kvmd/htserver.py
+++ b/kvmd/htserver.py
@@ -26,6 +26,7 @@ import asyncio
import contextlib
import dataclasses
import inspect
+import urllib.parse
import json
from typing import Tuple
@@ -187,8 +188,20 @@ def make_json_exception(err: Exception, status: Optional[int]=None) -> Response:
}, status=status)
-async def start_streaming(request: Request, content_type: str) -> StreamResponse:
- response = StreamResponse(status=200, reason="OK", headers={"Content-Type": content_type})
+async def start_streaming(
+ request: Request,
+ content_type: str,
+ content_length: int=-1,
+ file_name: str="",
+) -> StreamResponse:
+
+ response = StreamResponse(status=200, reason="OK")
+ response.content_type = content_type
+ if content_length >= 0:
+ response.content_length = content_length
+ if file_name:
+ file_name = urllib.parse.quote(file_name, safe="")
+ response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{file_name}"
await response.prepare(request)
return response
diff --git a/kvmd/plugins/msd/__init__.py b/kvmd/plugins/msd/__init__.py
index 741c0beb..1bb11413 100644
--- a/kvmd/plugins/msd/__init__.py
+++ b/kvmd/plugins/msd/__init__.py
@@ -132,6 +132,15 @@ class BaseMsd(BasePlugin):
raise NotImplementedError()
@contextlib.asynccontextmanager
+ async def read_image(self, name: str) -> AsyncGenerator[int, None]: # pylint: disable=unused-argument
+ if self is not None: # XXX: Vulture and pylint hack
+ raise NotImplementedError()
+ yield 1
+
+ async def read_image_chunk(self) -> bytes:
+ raise NotImplementedError()
+
+ @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]: # pylint: disable=unused-argument
if self is not None: # XXX: Vulture and pylint hack
raise NotImplementedError()
@@ -144,6 +153,40 @@ class BaseMsd(BasePlugin):
raise NotImplementedError()
+class MsdImageReader:
+ def __init__(self, path: str, chunk_size: int) -> None:
+ self.__name = os.path.basename(path)
+ self.__path = path
+ self.__chunk_size = chunk_size
+
+ self.__file: Optional[aiofiles.base.AiofilesContextManager] = None
+ self.__file_size: int = 0
+
+ async def open(self) -> "MsdImageReader":
+ assert self.__file is None
+ get_logger(1).info("Reading %r image from MSD ...", self.__name)
+ self.__file_size = os.stat(self.__path).st_size
+ self.__file = await aiofiles.open(self.__path, mode="rb") # type: ignore
+ return self
+
+ def get_size(self) -> int:
+ assert self.__file is not None
+ return self.__file_size
+
+ async def read(self) -> bytes:
+ assert self.__file is not None
+ return (await self.__file.read(self.__chunk_size)) # type: ignore
+
+ async def close(self) -> None:
+ assert self.__file is not None
+ logger = get_logger()
+ logger.info("Closed image reader ...")
+ try:
+ await self.__file.close() # type: ignore
+ except Exception:
+ logger.exception("Can't close image reader")
+
+
class MsdImageWriter:
def __init__(self, path: str, size: int, sync: int) -> None:
self.__name = os.path.basename(path)
diff --git a/kvmd/plugins/msd/disabled.py b/kvmd/plugins/msd/disabled.py
index 13def45e..ab076cd4 100644
--- a/kvmd/plugins/msd/disabled.py
+++ b/kvmd/plugins/msd/disabled.py
@@ -77,6 +77,15 @@ class Plugin(BaseMsd):
raise MsdDisabledError()
@contextlib.asynccontextmanager
+ async def read_image(self, name: str) -> AsyncGenerator[int, None]:
+ if self is not None: # XXX: Vulture and pylint hack
+ raise MsdDisabledError()
+ yield 1
+
+ async def read_image_chunk(self) -> bytes:
+ raise MsdDisabledError()
+
+ @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
if self is not None: # XXX: Vulture and pylint hack
raise MsdDisabledError()
diff --git a/kvmd/plugins/msd/otg/__init__.py b/kvmd/plugins/msd/otg/__init__.py
index 1f670de5..1149b39e 100644
--- a/kvmd/plugins/msd/otg/__init__.py
+++ b/kvmd/plugins/msd/otg/__init__.py
@@ -57,6 +57,7 @@ from .. import MsdImageNotSelected
from .. import MsdUnknownImageError
from .. import MsdImageExistsError
from .. import BaseMsd
+from .. import MsdImageReader
from .. import MsdImageWriter
from . import fs
@@ -136,6 +137,7 @@ class _State:
class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called
self,
+ read_chunk_size: int,
write_chunk_size: int,
sync_chunk_size: int,
@@ -148,6 +150,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
gadget: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None:
+ self.__read_chunk_size = read_chunk_size
self.__write_chunk_size = write_chunk_size
self.__sync_chunk_size = sync_chunk_size
@@ -162,6 +165,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__drive = Drive(gadget, instance=0, lun=0)
+ self.__reader: Optional[MsdImageReader] = None
self.__writer: Optional[MsdImageWriter] = None
self.__writer_tick = 0.0
@@ -175,6 +179,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
@classmethod
def get_plugin_options(cls) -> Dict:
return {
+ "read_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"write_chunk_size": Option(65536, type=functools.partial(valid_number, min=1024)),
"sync_chunk_size": Option(4194304, type=functools.partial(valid_number, min=1024)),
@@ -253,6 +258,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
@aiotools.atomic
async def cleanup(self) -> None:
+ await self.__close_reader()
await self.__close_writer()
# =====
@@ -318,6 +324,29 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__state.vd.connected = connected
@contextlib.asynccontextmanager
+ async def read_image(self, name: str) -> AsyncGenerator[int, None]:
+ async with self.__state.busy():
+ assert self.__state.storage
+ assert self.__state.vd
+
+ if self.__state.vd.connected or self.__drive.get_image_path():
+ raise MsdConnectedError()
+
+ path = os.path.join(self.__images_path, name)
+ if name not in self.__state.storage.images or not os.path.exists(path):
+ raise MsdUnknownImageError()
+
+ try:
+ self.__reader = await MsdImageReader(path, self.__read_chunk_size).open()
+ yield self.__reader.get_size()
+ finally:
+ await self.__close_reader()
+
+ async def read_image_chunk(self) -> bytes:
+ assert self.__reader
+ return (await self.__reader.read())
+
+ @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
try:
async with self.__state._region: # pylint: disable=protected-access
@@ -387,6 +416,11 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
# =====
+ async def __close_reader(self) -> None:
+ if self.__reader:
+ await self.__reader.close()
+ self.__reader = None
+
async def __close_writer(self) -> None:
if self.__writer:
await self.__writer.close()
diff --git a/kvmd/plugins/msd/relay/__init__.py b/kvmd/plugins/msd/relay/__init__.py
index 2cdc5796..2b45bce3 100644
--- a/kvmd/plugins/msd/relay/__init__.py
+++ b/kvmd/plugins/msd/relay/__init__.py
@@ -218,6 +218,17 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__connected = connected
@contextlib.asynccontextmanager
+ async def read_image(self, name: str) -> AsyncGenerator[int, None]:
+ async with self.__working():
+ if self is not None: # XXX: Vulture and pylint hack
+ raise MsdMultiNotSupported()
+ yield 1
+
+ async def read_image_chunk(self) -> bytes:
+ async with self.__working():
+ raise MsdMultiNotSupported()
+
+ @contextlib.asynccontextmanager
async def write_image(self, name: str, size: int) -> AsyncGenerator[int, None]:
async with self.__working():
async with self.__region:
diff --git a/web/kvm/index.html b/web/kvm/index.html
index 093133ee..ebcc824a 100644
--- a/web/kvm/index.html
+++ b/web/kvm/index.html
@@ -420,6 +420,9 @@
<select disabled id="msd-image-selector"></select>
</td>
<td>
+ <button disabled id="msd-download-button" title="Download image">&nbsp;&nbsp;&#128426;&nbsp;&nbsp;</button>
+ </td>
+ <td>
<button disabled id="msd-remove-button" title="Remove image"><b>&nbsp;&nbsp;&times;&nbsp;&nbsp;</b></button>
</td>
</tr>
diff --git a/web/kvm/navbar-msd.pug b/web/kvm/navbar-msd.pug
index 68d764c3..ae2e063e 100644
--- a/web/kvm/navbar-msd.pug
+++ b/web/kvm/navbar-msd.pug
@@ -37,6 +37,7 @@ li(id="msd-dropdown" class="right feature-disabled")
tr
td Image:
td(width="100%") #[select(disabled id="msd-image-selector")]
+ td #[button(disabled id="msd-download-button" title="Download image") &nbsp;&nbsp;&#128426;&nbsp;&nbsp;]
td #[button(disabled id="msd-remove-button" title="Remove image") #[b &nbsp;&nbsp;&times;&nbsp;&nbsp;]]
table(class="kv msd-cdrom-emulation feature-disabled")
tr
diff --git a/web/share/js/kvm/msd.js b/web/share/js/kvm/msd.js
index aff901d5..4e85239f 100644
--- a/web/share/js/kvm/msd.js
+++ b/web/share/js/kvm/msd.js
@@ -39,6 +39,7 @@ export function Msd() {
$("msd-led").title = "Unknown state";
$("msd-image-selector").onchange = __selectImage;
+ tools.el.setOnClick($("msd-download-button"), __clickDownloadButton);
tools.el.setOnClick($("msd-remove-button"), __clickRemoveButton);
tools.radio.setOnClick("msd-mode-radio", __clickModeRadio);
@@ -67,10 +68,16 @@ export function Msd() {
var __selectImage = function() {
tools.el.setEnabled($("msd-image-selector"), false);
+ tools.el.setEnabled($("msd-download-button"), false);
tools.el.setEnabled($("msd-remove-button"), false);
__sendParam("image", $("msd-image-selector").value);
};
+ var __clickDownloadButton = function() {
+ let name = $("msd-image-selector").value;
+ window.open(`/api/msd/read?image=${name}`);
+ };
+
var __clickRemoveButton = function() {
let name = $("msd-image-selector").value;
wm.confirm(`Are you sure you want to remove the image<br><b>${name}</b> from PiKVM?`).then(function(ok) {
@@ -244,6 +251,7 @@ export function Msd() {
tools.el.setEnabled($("msd-image-selector"), (online && s.features.multi && !s.drive.connected && !s.busy));
__applyStateImageSelector();
+ tools.el.setEnabled($("msd-download-button"), (online && s.features.multi && s.drive.image && !s.drive.connected && !s.busy));
tools.el.setEnabled($("msd-remove-button"), (online && s.features.multi && s.drive.image && !s.drive.connected && !s.busy));
tools.radio.setEnabled("msd-mode-radio", (online && s.features.cdrom && !s.drive.connected && !s.busy));