mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 09:59:15 -06:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02242c1908 | ||
|
|
509a22f6bd | ||
|
|
5572e1de35 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,3 +36,5 @@ kvmapp/kvm_system/kvm_system
|
||||
|
||||
# Release packages assembled by scripts/package.sh
|
||||
/build/
|
||||
kvmapp/server
|
||||
kvmapp/version
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -1,3 +1,71 @@
|
||||
## 2.5.3 (2026-11-03)
|
||||
|
||||
### Features
|
||||
|
||||
* Replaced the desktop image menu and the download page with a storage page that lists the media the emulated USB disk can carry, mounts and ejects them, and browses the files inside a medium over the same gadget, so the two machines exchange files without going through the terminal
|
||||
* Added uploads, downloads and deletes to the storage page, with search, sorting, folder deletion behind a confirmation, one transfer display for image and file transfers, cancellable uploads, a free space preflight and a checksum mismatch report
|
||||
* Browsed only media whose filesystem this device can read, so an ISO 9660 image is still served to the target machine but cannot be listed here, and limited CD-ROM mode to ISO 9660 and UDF images
|
||||
* Made the shared folder follow the target machine: while it owns the data partition the listing refreshes on a timer after dropping the dentry and inode caches, so a file the target machine wrote shows up within a second of its own writeback
|
||||
* Added a fine-grained permission system on top of the `admin` and `user` roles: administrators keep implicit full access, while every other account receives an explicit set of grants covering the console, power, media, terminal, device, network, updates, MCP, Tailscale and AI features, with the groups, risk marks and implied grants shown in a permission editor; mounting a medium needs the virtual disk, and the storage page names the account that has to enable it when the current account cannot
|
||||
* Added a view-only console session for an account that may watch but not send input: the pointer and keyboard capture, the mouse menu and the input region editor follow the grant, pages, menus, settings tabs and storage actions render only what the account is allowed to use, and an account without console access lands on a page offering the sections its grants open
|
||||
* Changed permission handling so an open session picks up the new grants without ending the login: a change that invalidates a cached capability drops the long-lived connection with a dedicated close code so the page reconnects, and only disabling an account ends the login
|
||||
* Added the choice of regenerating the device-wide MCP API key when an account loses the MCP grant, instead of rotating the key silently and disconnecting every MCP client
|
||||
* Kept the pre-2.6 image endpoints working as a compatibility layer over the new media API, so existing clients and scripts keep working
|
||||
* Treated UDF images, not only ISO 9660 ones, as optical media, and accepted uploads by extension (`.iso`/`.img`), leaving an unusable image to fail when it is mounted instead of while it is stored
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed HID input being lost after a medium was mounted or the gadget was otherwise reset, by reopening the HID devices with a retry instead of a single attempt that could fail while the new nodes were still coming up
|
||||
* Fixed `/data` staying read-only after a failed gadget script or a network switch
|
||||
* Fixed the settings menu icon sitting a few pixels below the other menu icons, and centered the dialog tab icons in their box
|
||||
* Fixed Chinese and Japanese punctuation being drawn by a Latin face in tooltips and dialogs, which pushed the ideographic comma and full stop above the baseline
|
||||
|
||||
### UI Improvements
|
||||
|
||||
* Gave the shared folder, ISO and IMG entries their own glyphs and picked file icons from the extension, so a listing can be read at a glance
|
||||
* Listed administrators above regular accounts and gave their rows a lighter card
|
||||
* Moved the PicoClaw chat composer to an antd text area and replaced the Radix scroll area with the native scroll container, so the settings dialog and the shortcut lists scroll like the rest of the app
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated every string the storage page, the permission editor, the view-only console and the MCP key rotation add for all 24 supported locales
|
||||
* Removed the keys the retired image menu and download page left behind
|
||||
|
||||
### Security
|
||||
|
||||
* Mounted images are opened through loop devices that refuse symlinks, and files inside a medium are opened without following links
|
||||
|
||||
### Chores
|
||||
|
||||
* Removed the desktop image menu, the download page and their web client code
|
||||
* Dropped the `@radix-ui/react-scroll-area` dependency
|
||||
* Documented the permission catalog, the split between mounting media and writing the library, and the grants that reach the whole device in `server/README.md` and `server/README_ZH.md`
|
||||
|
||||
## 2.5.2 (2026-09-29)
|
||||
|
||||
### Features
|
||||
|
||||
* Added a one-writer policy for the emulated USB disk and the `/data` partition: `/data` is mounted read-only from boot while the virtual disk serves the raw partition to the target machine, enabling the disk remounts it read-only and verifies the state before the target machine can write it, refusing to expose the disk when the remount fails, and releasing a medium hands the raw partition back to the target machine the same way
|
||||
* Added confirmation before a mounted medium and the virtual disk take the shared partition from each other, including a warning that a running image transfer is cancelled and that `/data` becomes read-only on this device
|
||||
* Added cancellation of a running image transfer before the partition handover, showed the transfer state in the image list and disabled the actions that cannot run while a medium is mounted, with a hint explaining why
|
||||
* Added names for the mounted medium and the emulated USB device taken from the image file, truncated to the SCSI inquiry product field and the USB product string and restored on unmount
|
||||
* Added streaming validation of uploaded images: ISO 9660 and UDF volume descriptors and raw disk images (GPT, APFS, HFS+, MBR) are accepted and anything else is rejected in seconds instead of after the whole transfer, with a free space check that keeps a 128 MiB reserve before any byte is received
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed mass storage mounts staying writable from the target machine: every mounted image is now served read-only, with the flag set before the LUN is attached, so the target machine can neither write to nor format it
|
||||
* Fixed the LUN flags surviving a gadget restart, which brought the shared partition back to the target machine read-only or as an optical drive after a medium had been mounted
|
||||
* Fixed the virtual disk switch staying on but unchangeable while a medium was mounted; the disk now reports itself on only while the LUN really carries the `/data` partition
|
||||
* Fixed the on-screen keyboard crashing the page with "Minified React error #130" as soon as it was opened, by importing the `react-simple-keyboard` component through its named export, which the CommonJS interop under Vite 8 no longer resolves from a default import
|
||||
|
||||
### Localization
|
||||
|
||||
* Translated the new virtual disk and image mounting strings for every locale
|
||||
|
||||
### Chores
|
||||
|
||||
* Added unit tests for the upload header check, the free space preflight and the image name truncation
|
||||
|
||||
## 2.5.1 (2026-09-09)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -66,8 +66,68 @@ turn:
|
||||
|
||||
NanoKVM uses two device-wide roles:
|
||||
|
||||
- `admin`: KVM access plus user, system, network, update, storage, terminal, script, MCP, and PicoClaw administration.
|
||||
- `user`: KVM video, keyboard, mouse, paste, power/reset, and Wake-on-LAN access.
|
||||
- `admin`: everything below, plus user management.
|
||||
- `user`: only what the account is granted.
|
||||
|
||||
A grant names an action on one party, and the catalog the server serves to the account editor
|
||||
carries its group, its risk, and the grants it implies:
|
||||
|
||||
| Group | Grants |
|
||||
| --- | --- |
|
||||
| console (this session) | `console.view`, `console.control` |
|
||||
| target machine | `power`, `medium.attach` |
|
||||
| media library (this device) | `library.read`, `library.write` |
|
||||
| this device | `terminal`, `device`, `network`, `update` |
|
||||
| services | `mcp`, `tailscale`, `ai` |
|
||||
|
||||
Implied grants are declared once, in the catalog, and the server resolves them before answering any
|
||||
request: `console.control` implies `console.view`, both `medium.attach` and `library.write` imply
|
||||
`library.read`, and every grant whose controls live in the console menu — `power`, `device`,
|
||||
`network`, `update`, `mcp`, `tailscale` and `ai` — implies `console.view` too, because no page
|
||||
outside the console renders them. Consequences worth knowing:
|
||||
|
||||
- An account can hold no console access at all. The web app then lands on a page that offers the
|
||||
sections the account may use (the media library, the terminal) instead of a console it cannot
|
||||
open. `library.read` is all that media access needs, because the other two media grants imply it,
|
||||
and only `terminal` and the media grants can stand on their own pages in the first place.
|
||||
- The media grants split along the trust boundary. `medium.attach` mounts or ejects a medium on the
|
||||
target machine and writes into the shared partition the target reads; `library.write` changes what
|
||||
the library keeps (uploads, deletes, the files inside an image); `library.read` lists and downloads.
|
||||
The file endpoints serve an image and the shared partition through one path, so the route admits
|
||||
either grant and each request is checked against the medium type it names. Every `.iso` and
|
||||
`.img` on the partition is a library entry, so uploading one through the shared partition, or
|
||||
deleting a file or folder that holds one, also needs `library.write`.
|
||||
- The emulated USB devices are neither media nor network: the virtual disk and the RNDIS adapter
|
||||
hand the target machine the raw data partition and a network interface, so both follow `device`.
|
||||
A medium can only be attached while a `device` holder has the virtual disk switched on, and the
|
||||
storage page says so when it is off.
|
||||
|
||||
Four grants reach further than their names suggest and amount to full device control. The catalog
|
||||
has two risk levels, and `warn` is reserved for them: the account editor marks those four and
|
||||
names them before the grants are saved:
|
||||
|
||||
- `terminal` is a root shell (the same one SSH gives) and can run saved and autostart scripts.
|
||||
- `mcp` hands out a device-wide API key that drives the keyboard, mouse and screenshots from
|
||||
outside the web session. The key is shared, so a copy keeps working after an account loses this
|
||||
grant. Whenever a change takes the grant away (revoked, demoted, disabled or deleted), the account
|
||||
editor warns and offers to regenerate the key, checked by default; regenerating disconnects every
|
||||
MCP client until it is given the new key, and an administrator who keeps the key accepts that a
|
||||
copy stays valid. The API takes the choice as `rotateMcpKey` (a body field on update, a query
|
||||
parameter on delete) and never rotates without it.
|
||||
- `ai` drives the device through the local loopback interface, which the other grants do not
|
||||
restrict.
|
||||
- `update` installs firmware and application packages. An offline package is checked only against
|
||||
the checksum its uploader supplies, so installing one is running code as root.
|
||||
|
||||
`device` carries no such mark, although it changes what the device does for everyone and cannot be
|
||||
narrowed per account. `device` turns SSH on or off (root login, with the device
|
||||
owner's password), reboots the device, hands the raw data partition or a network interface to the
|
||||
target machine through the emulated USB devices, and edits the OLED, HDMI, mDNS, memory, swap and
|
||||
HID settings. The editor describes it that way.
|
||||
|
||||
Two grants also change settings that affect everyone watching: `console.view` sets the video mode,
|
||||
resolution, frame rate and quality, and resets the HDMI capture; `power` manages the Wake-on-LAN
|
||||
device list. Both are described that way in the editor.
|
||||
|
||||
Administrators manage accounts from **Settings > Account**. Account data remains in
|
||||
`/etc/kvm/pwd`; the server migrates the legacy single-account JSON format in place and writes
|
||||
@@ -77,12 +137,31 @@ Users must confirm their current password when changing it themselves; administr
|
||||
non-owner users from the account manager. Only the device owner can change the device owner's
|
||||
password, because that password is also synchronized to the Linux root account.
|
||||
|
||||
All authenticated sessions are backed by the current account state. Disabling, deleting,
|
||||
changing the role or password of a user invalidates that user's HTTP and real-time connections
|
||||
without affecting other users. Multiple users may watch and control the KVM concurrently; input
|
||||
uses the existing cooperative HID coordinator, so simultaneous input can interleave.
|
||||
Video mode, quality, resolution, and MJPEG frame-detection controls remain shared KVM
|
||||
operations; when several users adjust them concurrently, the latest change applies device-wide.
|
||||
All authenticated sessions are backed by the current account state: every request is authorized
|
||||
against the account file, so a revoked grant stops working immediately without re-issuing a login.
|
||||
Disabling, deleting, or resetting the password of a user invalidates that user's HTTP and real-time
|
||||
connections without affecting other users. Changing the role or the grants of an account keeps its
|
||||
login valid: the account is told to refresh, and only the long-lived connections whose cached
|
||||
state depends on what changed are dropped (console, video stream, terminal, AI), so those clients
|
||||
reconnect under the new grants. An account that loses `console.view` is sent to the landing page
|
||||
instead of a console it can no longer open. Multiple
|
||||
users may watch and control the KVM concurrently; input uses the existing cooperative HID
|
||||
coordinator, so simultaneous input can interleave. A user without `console.control` keeps the
|
||||
video and the menu for its other grants, but the console rejects keyboard and mouse reports and
|
||||
the web UI marks the session as view only.
|
||||
Video mode, quality, resolution, MJPEG frame-detection controls, and resetting the HDMI capture
|
||||
require only `console.view` and stay shared KVM operations; when several users adjust them
|
||||
concurrently, the latest change applies device-wide. Enabling or disabling the HDMI capture and
|
||||
its idle timeout are device settings. `console.control` is input only: keyboard, mouse, paste, and
|
||||
the input region that maps pointer motion onto the target screen.
|
||||
|
||||
The account file stores an explicit grant set: a record written before fine-grained permissions
|
||||
keeps exactly what the user role used to grant, and the split media grants are translated on load,
|
||||
so the store never holds a missing set that would fall back to different behavior later. Grants
|
||||
this build does not know are preserved and ignored, so a downgrade cannot lock the device out.
|
||||
Creating a regular account without naming a grant set keeps that same meaning, so a script that only
|
||||
knows about roles gets the account the user role used to create; the account editor seeds a new
|
||||
regular account with `console.view` only, and an explicitly empty set really is empty.
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
|
||||
@@ -64,17 +64,75 @@ turn:
|
||||
|
||||
NanoKVM 使用两种设备级角色:
|
||||
|
||||
- `admin`:除 KVM 操作外,还可管理用户、系统、网络、更新、存储、终端、脚本、MCP 和 PicoClaw。
|
||||
- `user`:可使用 KVM 视频、键盘、鼠标、粘贴、目标机电源/复位和网络唤醒。
|
||||
- `admin`:拥有以下全部能力,并可管理用户。
|
||||
- `user`:仅拥有被授予的功能权限。
|
||||
|
||||
每项权限都描述“对谁做什么”,服务端下发给账户编辑器的权限目录还带有分组、风险等级和蕴含关系:
|
||||
|
||||
| 分组 | 权限 |
|
||||
| --- | --- |
|
||||
| 控制台(本会话) | `console.view`、`console.control` |
|
||||
| 被控机 | `power`、`medium.attach` |
|
||||
| 介质库(本设备) | `library.read`、`library.write` |
|
||||
| 本设备 | `terminal`、`device`、`network`、`update` |
|
||||
| 服务 | `mcp`、`tailscale`、`ai` |
|
||||
|
||||
蕴含关系只在权限目录里声明一次,并由服务端在应答任何请求前解析:`console.control` 蕴含
|
||||
`console.view`,`medium.attach` 与 `library.write` 都蕴含 `library.read`;而控制项位于控制台菜单里的
|
||||
权限——`power`、`device`、`network`、`update`、`mcp`、`tailscale`、`ai`——同样蕴含 `console.view`,
|
||||
因为控制台之外没有任何页面渲染它们。由此得到几个行为:
|
||||
|
||||
- 账户可以完全没有控制台权限。此时 Web 界面会落到一个提示页,列出该账户可用的页面
|
||||
(介质库、终端),而不是打开一个它无权访问的控制台。媒体功能只需要 `library.read`,
|
||||
因为另外两项媒体权限都蕴含它;也只有 `terminal` 与媒体权限能拥有控制台之外的独立页面。
|
||||
- 媒体权限按信任边界拆分:`medium.attach` 在被控机上挂载/弹出介质,并写入被控机可读的共享分区;
|
||||
`library.write` 修改库内容(上传、删除、镜像内的文件);`library.read` 负责列表与下载。
|
||||
文件相关接口用同一路径同时服务镜像与共享分区,因此路由同时接受这两项权限之一,
|
||||
再按请求指定的介质类型做校验。分区上的每个 `.iso`、`.img` 都属于介质库,因此经共享分区上传这类文件,
|
||||
或删除本身是/内含这类文件的文件或文件夹,同样需要 `library.write`。
|
||||
- 模拟的 USB 设备既不属于媒体也不属于网络:虚拟磁盘与 RNDIS 虚拟网卡分别把设备的原始数据分区
|
||||
和一块网卡交给被控机,因此都归 `device`。只有拥有 `device` 权限的账户开启虚拟磁盘后,才能挂载
|
||||
镜像;磁盘未开启时存储页面会给出提示。
|
||||
|
||||
以下四项权限的实际范围比名称更大,等同于完整的设备控制权;权限目录只有两个风险等级,`warn`
|
||||
专用于它们,账户编辑器会做标记并在保存前按名称列出:
|
||||
|
||||
- `terminal`:即 root shell(与 SSH 相同),并可运行已保存的脚本和开机自启脚本。
|
||||
- `mcp`:会下发设备级 API Key,可在 Web 会话之外控制键鼠并获取截图。Key 为共用,账户失去该权限后已复制的
|
||||
Key 仍然有效。任何会让账户失去该权限的操作(撤销、降级、禁用或删除),账户编辑器都会警告并提供「重新生成
|
||||
Key」选项(默认勾选);重新生成会断开所有 MCP 客户端直到换用新 Key,管理员选择保留则自行承担副本仍有效的风险。
|
||||
API 通过 `rotateMcpKey` 传递该选择(更新时放在请求体,删除时放在查询参数),不带它就不会轮换。
|
||||
- `ai`:通过本地回环接口操作设备,不受其他权限限制。
|
||||
- `update`:安装固件与应用更新包。离线包只校验上传者自己提供的校验和,安装它即等于以 root 运行代码。
|
||||
|
||||
`device` 不做该标记:它仍在本账户被授予的设置范围内,但会改变设备对所有人生效的行为,
|
||||
且无法按账户收窄。`device` 可开关 SSH(root,口令为设备所有者口令)、重启设备、通过模拟的 USB 设备把
|
||||
原始数据分区或网卡交给被控机,并修改 OLED、HDMI、mDNS、内存、交换与 HID 设置。编辑器中对此同样有说明。
|
||||
|
||||
另有两项权限会改动影响所有观看者的设置:`console.view` 会设置视频模式、分辨率、帧率与画质,
|
||||
并复位 HDMI 采集;`power` 会管理网络唤醒设备列表。编辑器中对这两项都有明确说明。
|
||||
|
||||
管理员可在**设置 > 账户**中管理用户。账户数据继续保存在 `/etc/kvm/pwd`;服务会原地迁移旧版单账户
|
||||
JSON,并以 `0600` 权限原子写入多用户格式。沿用同一路径可保持长按 BOOT 键重置密码的现有行为。
|
||||
用户自助修改密码时必须验证当前密码;管理员可在账户管理中重置非设备所有者的密码。由于设备所有者
|
||||
密码还会同步至 Linux root 账户,因此只有设备所有者本人可以修改自己的密码。
|
||||
|
||||
所有登录会话都会校验当前账户状态。禁用、删除、修改角色或密码会立即撤销该用户的 HTTP 与实时连接,
|
||||
且不会影响其他用户。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID 协调器,因此同时输入可能交错。
|
||||
视频模式、画质、分辨率和 MJPEG 帧检测仍属于共享 KVM 操作;多人同时调整时,以最后一次设备级修改为准。
|
||||
所有登录会话都会校验当前账户状态:每个请求都按账户文件重新授权,因此被撤销的权限会立即失效,
|
||||
无需重新登录。禁用、删除或重置密码会立即撤销该用户的 HTTP 与实时连接,且不会影响其他用户。
|
||||
修改角色或权限不会失效登录:账户会收到刷新通知,只会断开缓存了相关状态的长期连接
|
||||
(控制台、视频流、终端、AI),这些客户端随后会以新权限重连。失去 `console.view` 的账户会被
|
||||
转到提示页,而不是一个无法打开的控制台。多个用户可同时观看和协作控制 KVM;输入沿用现有 HID
|
||||
协调器,因此同时输入可能交错。没有 `console.control` 的用户仍可观看画面并使用其其他权限对应的菜单,
|
||||
但控制台会丢弃其键鼠报文,Web 界面会将会话标记为只读。
|
||||
视频模式、画质、分辨率、MJPEG 帧检测以及复位 HDMI 采集只要求 `console.view`,仍属于共享 KVM 操作;
|
||||
多人同时调整时,以最后一次设备级修改为准。启用/关闭 HDMI 采集及其空闲超时则属于设备设置。
|
||||
`console.control` 仅限输入:键盘、鼠标、粘贴,以及将指针位移映射到目标机画面的输入区域。
|
||||
|
||||
账户文件保存的是显式权限集合:细粒度权限出现之前写入的记录,在读取时会物化为当时 user 角色的
|
||||
能力,拆分过的媒体权限也会被翻译成新名称,因此存储中不会留下“缺失即回退到另一套行为”的状态。
|
||||
本版本不认识的权限会被原样保留并忽略,因此回滚版本不会把设备锁在门外。
|
||||
新建普通账户时若不指定权限集合,含义同上:只认识角色的脚本仍会得到 user 角色过去创建出的账户;
|
||||
账户编辑器新建普通账户时只预置 `console.view`,而显式传入的空集合就是真的空。
|
||||
|
||||
## 编译部署
|
||||
|
||||
|
||||
322
server/authn/permissions.go
Normal file
322
server/authn/permissions.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Permission identifies a grantable capability. The admin role implicitly holds
|
||||
// every permission; the user role only holds the permissions stored on the
|
||||
// account.
|
||||
//
|
||||
// A permission names what it lets the account do, not the settings page it
|
||||
// lives on, and every grant that changes something shared says so in the
|
||||
// catalog's risk and effect metadata instead of in the label alone.
|
||||
type Permission string
|
||||
|
||||
const (
|
||||
// Console grants.
|
||||
PermConsoleView Permission = "console.view"
|
||||
PermConsoleControl Permission = "console.control"
|
||||
|
||||
// Target-machine grants.
|
||||
PermPower Permission = "power"
|
||||
PermMediumAttach Permission = "medium.attach"
|
||||
|
||||
// Media library grants.
|
||||
PermLibraryRead Permission = "library.read"
|
||||
PermLibraryWrite Permission = "library.write"
|
||||
|
||||
// Host grants.
|
||||
PermTerminal Permission = "terminal"
|
||||
PermDevice Permission = "device"
|
||||
PermNetwork Permission = "network"
|
||||
PermUpdate Permission = "update"
|
||||
|
||||
// Service grants.
|
||||
PermMcp Permission = "mcp"
|
||||
PermTailscale Permission = "tailscale"
|
||||
PermAi Permission = "ai"
|
||||
)
|
||||
|
||||
// Group names the party a grant reaches. The account editor renders one section
|
||||
// per group, so the sections stay meaningful as the catalog grows.
|
||||
type Group string
|
||||
|
||||
const (
|
||||
// GroupConsole is what an account may do with the console it is watching.
|
||||
GroupConsole Group = "console"
|
||||
// GroupTarget is what it may do to the machine behind the console.
|
||||
GroupTarget Group = "target"
|
||||
// GroupLibrary is what it may do with the media library on this device.
|
||||
GroupLibrary Group = "library"
|
||||
// GroupHost is what it may change on this device.
|
||||
GroupHost Group = "host"
|
||||
// GroupServices are the add-ons that reach the device on their own.
|
||||
GroupServices Group = "services"
|
||||
)
|
||||
|
||||
// Risk states how far a grant reaches, and the catalog carries two levels: a
|
||||
// plain grant, and the grants that amount to full device control, which the
|
||||
// account editor marks and names before the grants are saved.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
// RiskInfo is a grant that stays within the feature it names.
|
||||
RiskInfo Risk = "info"
|
||||
// RiskWarn is a grant that amounts to full device control: a root shell,
|
||||
// the AI loopback, an API key that drives input outside the web session, and
|
||||
// a package install whose checksum the uploader supplies.
|
||||
RiskWarn Risk = "warn"
|
||||
)
|
||||
|
||||
// Descriptor is one catalog entry: the grant, the group it belongs to, the risk
|
||||
// an administrator takes by granting it, and the grants it implies.
|
||||
type Descriptor struct {
|
||||
Permission Permission `json:"id"`
|
||||
Group Group `json:"group"`
|
||||
Risk Risk `json:"risk"`
|
||||
Implies []Permission `json:"implies,omitempty"`
|
||||
}
|
||||
|
||||
// Implied grants are declared once here instead of as unions at every call
|
||||
// site: control without the picture is meaningless, nothing can be attached or
|
||||
// changed in the library without being allowed to see it first, and the grants
|
||||
// whose controls live in the console menu imply the console, because no page
|
||||
// outside it renders them and a grant nothing can use is a promise the app
|
||||
// cannot keep. Terminal and the media grants stand on their own pages.
|
||||
var permissionCatalog = []Descriptor{
|
||||
{Permission: PermConsoleView, Group: GroupConsole, Risk: RiskInfo},
|
||||
{
|
||||
Permission: PermConsoleControl,
|
||||
Group: GroupConsole,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermPower,
|
||||
Group: GroupTarget,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermMediumAttach,
|
||||
Group: GroupTarget,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermLibraryRead},
|
||||
},
|
||||
{Permission: PermLibraryRead, Group: GroupLibrary, Risk: RiskInfo},
|
||||
{
|
||||
Permission: PermLibraryWrite,
|
||||
Group: GroupLibrary,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermLibraryRead},
|
||||
},
|
||||
{Permission: PermTerminal, Group: GroupHost, Risk: RiskWarn},
|
||||
{
|
||||
Permission: PermDevice,
|
||||
Group: GroupHost,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermNetwork,
|
||||
Group: GroupHost,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermUpdate,
|
||||
Group: GroupHost,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermMcp,
|
||||
Group: GroupServices,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermTailscale,
|
||||
Group: GroupServices,
|
||||
Risk: RiskInfo,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
{
|
||||
Permission: PermAi,
|
||||
Group: GroupServices,
|
||||
Risk: RiskWarn,
|
||||
Implies: []Permission{PermConsoleView},
|
||||
},
|
||||
}
|
||||
|
||||
// Grants of earlier releases, kept so an account file written by one of them
|
||||
// keeps meaning what it meant. media.mount and media.manage split into the
|
||||
// attach/read/write grants above.
|
||||
const (
|
||||
legacyPermMediaMount Permission = "media.mount"
|
||||
legacyPermMediaManage Permission = "media.manage"
|
||||
)
|
||||
|
||||
// Catalog returns the catalog in display order.
|
||||
func Catalog() []Descriptor {
|
||||
catalog := make([]Descriptor, len(permissionCatalog))
|
||||
for index, descriptor := range permissionCatalog {
|
||||
catalog[index] = descriptor
|
||||
catalog[index].Implies = append([]Permission(nil), descriptor.Implies...)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
// PermissionIDs returns every grantable permission in catalog order.
|
||||
func PermissionIDs() []Permission {
|
||||
ids := make([]Permission, 0, len(permissionCatalog))
|
||||
for _, descriptor := range permissionCatalog {
|
||||
ids = append(ids, descriptor.Permission)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func IsValidPermission(permission Permission) bool {
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if descriptor.Permission == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidatePermissions rejects unknown or duplicate permissions. It is for grant
|
||||
// sets arriving in a request; the grant set stored on an account is validated by
|
||||
// ValidateStoredPermissions, which tolerates ids this build does not know.
|
||||
func ValidatePermissions(permissions []Permission) error {
|
||||
seen := make(map[Permission]struct{}, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
if !IsValidPermission(permission) {
|
||||
return fmt.Errorf("unknown permission: %q", permission)
|
||||
}
|
||||
if _, exists := seen[permission]; exists {
|
||||
return fmt.Errorf("duplicate permission: %q", permission)
|
||||
}
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateStoredPermissions only rejects duplicates. An account file written by
|
||||
// a newer release carries grants this build cannot evaluate, and refusing to
|
||||
// load it would lock the device out instead of ignoring a capability that is
|
||||
// unavailable here anyway.
|
||||
func ValidateStoredPermissions(permissions []Permission) error {
|
||||
seen := make(map[Permission]struct{}, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
if _, exists := seen[permission]; exists {
|
||||
return fmt.Errorf("duplicate permission: %q", permission)
|
||||
}
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Has reports whether the set contains the permission. Administrators are not
|
||||
// special-cased: callers that should honor the role's implicit grants resolve
|
||||
// EffectivePermissions first.
|
||||
func Has(permissions []Permission, permission Permission) bool {
|
||||
for _, granted := range permissions {
|
||||
if granted == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Closure resolves the grants an account ends up holding: the set itself plus
|
||||
// everything it implies, transitively, in catalog order.
|
||||
func Closure(permissions []Permission) []Permission {
|
||||
granted := closureSet(permissions)
|
||||
|
||||
resolved := make([]Permission, 0, len(granted))
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if _, ok := granted[descriptor.Permission]; ok {
|
||||
resolved = append(resolved, descriptor.Permission)
|
||||
delete(granted, descriptor.Permission)
|
||||
}
|
||||
}
|
||||
// Keep ids this build does not know in the order they were stored, so they
|
||||
// still round-trip through the client.
|
||||
for _, permission := range permissions {
|
||||
if _, ok := granted[permission]; ok {
|
||||
resolved = append(resolved, permission)
|
||||
delete(granted, permission)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func closureSet(permissions []Permission) map[Permission]struct{} {
|
||||
granted := make(map[Permission]struct{}, len(permissions))
|
||||
var add func(Permission)
|
||||
add = func(permission Permission) {
|
||||
if _, exists := granted[permission]; exists {
|
||||
return
|
||||
}
|
||||
granted[permission] = struct{}{}
|
||||
for _, descriptor := range permissionCatalog {
|
||||
if descriptor.Permission != permission {
|
||||
continue
|
||||
}
|
||||
for _, implied := range descriptor.Implies {
|
||||
add(implied)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
add(permission)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
// NormalizeStoredPermissions translates the grants an earlier release wrote and
|
||||
// removes duplicates, so the store holds one spelling of every capability.
|
||||
// Unknown ids are preserved: a downgrade must not silently drop a grant.
|
||||
func NormalizeStoredPermissions(permissions []Permission) []Permission {
|
||||
normalized := make([]Permission, 0, len(permissions)+2)
|
||||
appendOnce := func(permission Permission) {
|
||||
if !Has(normalized, permission) {
|
||||
normalized = append(normalized, permission)
|
||||
}
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
switch permission {
|
||||
case legacyPermMediaMount:
|
||||
// Attaching a medium is what let a watcher read the library.
|
||||
appendOnce(PermMediumAttach)
|
||||
appendOnce(PermLibraryRead)
|
||||
case legacyPermMediaManage:
|
||||
appendOnce(PermLibraryRead)
|
||||
appendOnce(PermLibraryWrite)
|
||||
default:
|
||||
appendOnce(permission)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// legacyUserPermissions preserves the capabilities the user role had before
|
||||
// fine-grained permissions existed: full console access and power control.
|
||||
func legacyUserPermissions() []Permission {
|
||||
return []Permission{PermConsoleView, PermConsoleControl, PermPower}
|
||||
}
|
||||
|
||||
// EffectivePermissions resolves what a user can actually do: administrators
|
||||
// hold the whole catalog, regular users hold their stored grants and whatever
|
||||
// those imply.
|
||||
func EffectivePermissions(user *User) []Permission {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return PermissionIDs()
|
||||
}
|
||||
return Closure(user.Permissions)
|
||||
}
|
||||
86
server/authn/permissions_test.go
Normal file
86
server/authn/permissions_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosureResolvesImpliedGrants(t *testing.T) {
|
||||
cases := []struct {
|
||||
grants []Permission
|
||||
want []Permission
|
||||
}{
|
||||
{[]Permission{PermConsoleControl}, []Permission{PermConsoleView, PermConsoleControl}},
|
||||
{[]Permission{PermMediumAttach}, []Permission{PermMediumAttach, PermLibraryRead}},
|
||||
{[]Permission{PermLibraryWrite}, []Permission{PermLibraryRead, PermLibraryWrite}},
|
||||
{[]Permission{PermTerminal}, []Permission{PermTerminal}},
|
||||
{[]Permission{"future.grant", PermMcp}, []Permission{PermConsoleView, PermMcp, "future.grant"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Closure(tc.grants); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("Closure(%v) = %v, want %v", tc.grants, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTranslatesLegacyMediaGrants(t *testing.T) {
|
||||
got := NormalizeStoredPermissions([]Permission{legacyPermMediaMount, legacyPermMediaManage, PermPower})
|
||||
want := []Permission{PermMediumAttach, PermLibraryRead, PermLibraryWrite, PermPower}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("NormalizeStoredPermissions = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReturnsPreviousAndKeepsUnknownGrants(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pwd")
|
||||
store := NewStore(path)
|
||||
if err := store.Create("alice", "password", RoleUser, PermConsoleView); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A newer release stored a grant this build does not know.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var db database
|
||||
if err = json.Unmarshal(data, &db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index := userIndex(db.Users, "alice")
|
||||
db.Users[index].Permissions = append(db.Users[index].Permissions, "future.grant")
|
||||
if data, err = json.Marshal(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
grants := []Permission{PermConsoleControl}
|
||||
previous, updated, err := store.Update("admin", "alice", UserPatch{Permissions: &grants})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(previous.Permissions, []Permission{PermConsoleView, "future.grant"}) {
|
||||
t.Fatalf("previous permissions = %v", previous.Permissions)
|
||||
}
|
||||
if !slices.Equal(updated.Permissions, []Permission{PermConsoleControl, "future.grant"}) {
|
||||
t.Fatalf("updated permissions = %v", updated.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyFullControlGrantsWarn(t *testing.T) {
|
||||
var warned []Permission
|
||||
for _, descriptor := range Catalog() {
|
||||
if descriptor.Risk == RiskWarn {
|
||||
warned = append(warned, descriptor.Permission)
|
||||
}
|
||||
}
|
||||
want := []Permission{PermTerminal, PermUpdate, PermMcp, PermAi}
|
||||
if !slices.Equal(warned, want) {
|
||||
t.Fatalf("warned grants = %v, want %v", warned, want)
|
||||
}
|
||||
}
|
||||
@@ -42,25 +42,28 @@ var (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenVersion uint64 `json:"tokenVersion"`
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TokenVersion uint64 `json:"tokenVersion"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role Role `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type UserPatch struct {
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
Role *Role
|
||||
Enabled *bool
|
||||
Permissions *[]Permission
|
||||
}
|
||||
|
||||
type database struct {
|
||||
@@ -115,11 +118,16 @@ func (s *Store) List() ([]UserInfo, error) {
|
||||
}
|
||||
|
||||
users := make([]UserInfo, 0, len(db.Users))
|
||||
for _, user := range db.Users {
|
||||
for index := range db.Users {
|
||||
user := &db.Users[index]
|
||||
// The stored grants, not the effective ones: the editor shows what an
|
||||
// administrator set, and what an account implies is derived from the
|
||||
// catalog instead of being written back on every save.
|
||||
users = append(users, UserInfo{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Enabled: user.Enabled,
|
||||
Permissions: clonePermissions(user.Permissions),
|
||||
SystemAccount: user.SystemAccount,
|
||||
})
|
||||
}
|
||||
@@ -192,7 +200,7 @@ func (s *Store) ValidateToken(username string, tokenVersion uint64) (*User, erro
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(username, password string, role Role) error {
|
||||
func (s *Store) Create(username, password string, role Role, permissions ...Permission) error {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,6 +210,16 @@ func (s *Store) Create(username, password string, role Role) error {
|
||||
if !IsValidRole(role) {
|
||||
return errors.New("invalid role")
|
||||
}
|
||||
// A nil set means the caller named none at all, which keeps the meaning it
|
||||
// had before grants existed: an account created by a script that only knows
|
||||
// about roles gets what the user role granted then. An explicit empty set is
|
||||
// a decision and stays empty, so the account really holds nothing.
|
||||
if permissions == nil && role == RoleUser {
|
||||
permissions = legacyUserPermissions()
|
||||
}
|
||||
if err := ValidatePermissions(permissions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -227,37 +245,66 @@ func (s *Store) Create(username, password string, role Role) error {
|
||||
Role: role,
|
||||
Enabled: true,
|
||||
TokenVersion: tokenVersion,
|
||||
Permissions: NormalizeStoredPermissions(permissions),
|
||||
})
|
||||
return s.saveLocked(db)
|
||||
}
|
||||
|
||||
func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
|
||||
// Update applies the patch and returns the account as it was before and after,
|
||||
// both read under the store lock, so a caller comparing the two sees exactly the
|
||||
// change it made even while another update runs.
|
||||
func (s *Store) Update(actor, username string, patch UserPatch) (previous, updated *User, err error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
db, err := s.loadLocked(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
index := userIndex(db.Users, username)
|
||||
if index < 0 {
|
||||
return nil, ErrUserNotFound
|
||||
return nil, nil, ErrUserNotFound
|
||||
}
|
||||
previous = cloneUser(&db.Users[index])
|
||||
user := db.Users[index]
|
||||
|
||||
if patch.Role != nil && !IsValidRole(*patch.Role) {
|
||||
return nil, errors.New("invalid role")
|
||||
return nil, nil, errors.New("invalid role")
|
||||
}
|
||||
// The patch is normalized before it is compared and stored, so a caller that
|
||||
// still sends the split media grants does not look like a change once they
|
||||
// resolve to the grants they now mean.
|
||||
granted := user.Permissions
|
||||
if patch.Permissions != nil {
|
||||
if err := ValidatePermissions(*patch.Permissions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
granted = NormalizeStoredPermissions(*patch.Permissions)
|
||||
// A request can only name grants this build knows, so the ones it does
|
||||
// not (written by a newer release) are carried over instead of being
|
||||
// dropped by an editor that never showed them.
|
||||
for _, permission := range user.Permissions {
|
||||
if !IsValidPermission(permission) && !Has(granted, permission) {
|
||||
granted = append(granted, permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
if actor == username && user.Role == RoleAdmin &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSelfModification
|
||||
return nil, nil, ErrSelfModification
|
||||
}
|
||||
if user.SystemAccount &&
|
||||
((patch.Role != nil && *patch.Role != RoleAdmin) || (patch.Enabled != nil && !*patch.Enabled)) {
|
||||
return nil, ErrSystemAccount
|
||||
return nil, nil, ErrSystemAccount
|
||||
}
|
||||
|
||||
changed := false
|
||||
// Only disabling the account invalidates its login. A role change does not:
|
||||
// the principal is rebuilt from the account file on every request, and the
|
||||
// caller refreshes the user's long-lived connections so they reconnect under
|
||||
// the role's new grants. Bumping the token version for a role change would
|
||||
// log the account out mid-session just because it was promoted or demoted.
|
||||
loginInvalidated := false
|
||||
if patch.Role != nil && user.Role != *patch.Role {
|
||||
user.Role = *patch.Role
|
||||
changed = true
|
||||
@@ -265,20 +312,30 @@ func (s *Store) Update(actor, username string, patch UserPatch) (*User, error) {
|
||||
if patch.Enabled != nil && user.Enabled != *patch.Enabled {
|
||||
user.Enabled = *patch.Enabled
|
||||
changed = true
|
||||
loginInvalidated = true
|
||||
}
|
||||
if patch.Permissions != nil && !permissionsEqual(user.Permissions, granted) {
|
||||
user.Permissions = granted
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return &user, nil
|
||||
return previous, cloneUser(&user), nil
|
||||
}
|
||||
|
||||
db.Users[index] = user
|
||||
if enabledAdminCount(db.Users) == 0 {
|
||||
return nil, ErrLastAdmin
|
||||
return nil, nil, ErrLastAdmin
|
||||
}
|
||||
// Permission and role updates keep the login valid: the principal is rebuilt
|
||||
// from the store on every request, so new grants apply immediately and
|
||||
// revoked ones are enforced by dropping connections from the handler.
|
||||
if loginInvalidated {
|
||||
db.Users[index].TokenVersion++
|
||||
}
|
||||
db.Users[index].TokenVersion++
|
||||
if err = s.saveLocked(db); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return cloneUser(&db.Users[index]), nil
|
||||
return previous, cloneUser(&db.Users[index]), nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(actor, username string) error {
|
||||
@@ -378,6 +435,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
if defaultErr != nil {
|
||||
return nil, defaultErr
|
||||
}
|
||||
materializePermissions(db)
|
||||
if migrate {
|
||||
if saveErr := s.saveLocked(db); saveErr != nil {
|
||||
return nil, saveErr
|
||||
@@ -391,6 +449,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
|
||||
var db database
|
||||
if err = json.Unmarshal(data, &db); err == nil && db.Version != 0 {
|
||||
materializePermissions(&db)
|
||||
if err = validateDatabase(&db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -420,6 +479,7 @@ func (s *Store) loadLocked(migrate bool) (*database, error) {
|
||||
SystemAccount: true,
|
||||
}},
|
||||
}
|
||||
materializePermissions(&db)
|
||||
if migrate {
|
||||
if err = s.saveLocked(&db); err != nil {
|
||||
return nil, err
|
||||
@@ -528,6 +588,9 @@ func validateDatabase(db *database) error {
|
||||
if user.PasswordHash == "" || !IsValidRole(user.Role) {
|
||||
return errors.New("account file contains an invalid user")
|
||||
}
|
||||
if err := ValidateStoredPermissions(user.Permissions); err != nil {
|
||||
return fmt.Errorf("account file contains invalid permissions for %q: %w", user.Username, err)
|
||||
}
|
||||
if _, exists := seen[user.Username]; exists {
|
||||
return errors.New("account file contains duplicate usernames")
|
||||
}
|
||||
@@ -565,6 +628,46 @@ func userIndex(users []User, username string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// materializePermissions replaces the grant shapes of earlier releases with an
|
||||
// explicit set: an account that predates fine-grained permissions keeps exactly
|
||||
// what the user role used to grant, and the split media grants are translated.
|
||||
// After this the store never holds a nil set, so "no grants" and "never
|
||||
// configured" can no longer be confused, and no caller has to special-case nil.
|
||||
func materializePermissions(db *database) {
|
||||
for index := range db.Users {
|
||||
if db.Users[index].Permissions == nil {
|
||||
db.Users[index].Permissions = legacyUserPermissions()
|
||||
continue
|
||||
}
|
||||
db.Users[index].Permissions = NormalizeStoredPermissions(db.Users[index].Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func clonePermissions(permissions []Permission) []Permission {
|
||||
return append([]Permission{}, permissions...)
|
||||
}
|
||||
|
||||
func permissionsEqual(a, b []Permission) bool {
|
||||
if (a == nil) != (b == nil) {
|
||||
return false
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
// The stored order carries no meaning, so a reordering must not look like
|
||||
// a change worth writing back.
|
||||
seen := make(map[Permission]struct{}, len(a))
|
||||
for _, permission := range a {
|
||||
seen[permission] = struct{}{}
|
||||
}
|
||||
for _, permission := range b {
|
||||
if _, exists := seen[permission]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func enabledAdminCount(users []User) int {
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
|
||||
@@ -96,14 +96,14 @@ func TestUserLifecycleAndTokenRevocation(t *testing.T) {
|
||||
}
|
||||
|
||||
disabled := false
|
||||
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
|
||||
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := store.Authenticate("alice", "new-password"); err != nil || ok {
|
||||
t.Fatalf("disabled login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
enabled := true
|
||||
if _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
|
||||
if _, _, err = store.Update("admin", "alice", UserPatch{Enabled: &enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.Delete("admin", "alice"); err != nil {
|
||||
@@ -120,7 +120,7 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
|
||||
t.Fatalf("default login: ok=%v err=%v", ok, err)
|
||||
}
|
||||
userRole := RoleUser
|
||||
if _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
|
||||
if _, _, err := store.Update("admin", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSelfModification) {
|
||||
t.Fatalf("self demotion error = %v", err)
|
||||
}
|
||||
if err := store.Delete("admin", "admin"); !errors.Is(err, ErrSelfDelete) {
|
||||
@@ -130,10 +130,10 @@ func TestLastAdminAndSelfProtection(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled := false
|
||||
if _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
|
||||
if _, _, err := store.Update("second", "admin", UserPatch{Enabled: &disabled}); !errors.Is(err, ErrSystemAccount) {
|
||||
t.Fatalf("disable device owner error = %v", err)
|
||||
}
|
||||
if _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
|
||||
if _, _, err := store.Update("second", "admin", UserPatch{Role: &userRole}); !errors.Is(err, ErrSystemAccount) {
|
||||
t.Fatalf("demote device owner error = %v", err)
|
||||
}
|
||||
if err := store.Delete("second", "admin"); !errors.Is(err, ErrSystemAccount) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"NanoKVM-Server/logger"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/router"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
"NanoKVM-Server/utils"
|
||||
@@ -49,6 +50,11 @@ func initialize() {
|
||||
// run mouse jiggler
|
||||
jiggler.GetJiggler().Run()
|
||||
|
||||
// A medium left mounted by a crash or a restart keeps its loop device
|
||||
// busy; nothing can be mounted by this process yet, so whatever is there
|
||||
// is stale.
|
||||
diskfs.CleanupStale()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
go func() {
|
||||
@@ -123,5 +129,6 @@ func run() {
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
diskfs.CleanupStale()
|
||||
common.GetKvmVision().Close()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,18 @@ const (
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
Username string
|
||||
Role authn.Role
|
||||
Username string
|
||||
Role authn.Role
|
||||
Permissions []authn.Permission
|
||||
}
|
||||
|
||||
// Has reports whether the principal effectively holds the permission.
|
||||
// Administrators always do.
|
||||
func (p Principal) Has(permission authn.Permission) bool {
|
||||
if p.Role == authn.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
return authn.Has(p.Permissions, permission)
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
@@ -48,8 +58,34 @@ func CheckToken() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
requestContext, cancel := context.WithCancel(c.Request.Context())
|
||||
sessionHandle := newSessionHandle()
|
||||
requestContext = WithSessionHandle(requestContext, sessionHandle)
|
||||
c.Request = c.Request.WithContext(requestContext)
|
||||
unregister := activeSessions.register(principal.Username, cancel)
|
||||
channel := SessionChannelForPath(c.Request.URL.Path)
|
||||
unregister := activeSessions.register(
|
||||
principal.Username,
|
||||
cancel,
|
||||
sessionHandle,
|
||||
channel,
|
||||
)
|
||||
// The principal was resolved before this session could be found by a
|
||||
// revocation, so a grant edit landing in between would miss it. Now that
|
||||
// it is registered, any later edit drops it; re-reading the account picks
|
||||
// up any earlier one. Only the long-lived channels are dropped by a grant
|
||||
// edit, so a short request keeps the principal it was authorized with.
|
||||
if channel != "" {
|
||||
user, err := authn.DefaultStore.ValidateToken(principal.Username, token.TokenVersion)
|
||||
if err != nil {
|
||||
unregister()
|
||||
cancel()
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
principal.Role = user.Role
|
||||
principal.Permissions = authn.EffectivePermissions(user)
|
||||
c.Set(principalContextKey, principal)
|
||||
}
|
||||
|
||||
timer := time.AfterFunc(time.Until(token.ExpiresAt.Time), cancel)
|
||||
go watchSessionState(requestContext, cancel, principal.Username, token.TokenVersion, sessionRecheckDelay)
|
||||
defer func() {
|
||||
@@ -90,8 +126,31 @@ func RequireRole(roles ...authn.Role) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if _, ok = allowed[principal.Role]; !ok {
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
c.Abort()
|
||||
abortForbidden(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission allows the request when the principal holds at least one
|
||||
// of the listed permissions. Administrators implicitly hold every permission.
|
||||
func RequirePermission(permissions ...authn.Permission) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
principal, ok := CurrentPrincipal(c)
|
||||
if !ok {
|
||||
abortUnauthorized(c)
|
||||
return
|
||||
}
|
||||
allowed := false
|
||||
for _, permission := range permissions {
|
||||
if principal.Has(permission) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
abortForbidden(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
@@ -138,7 +197,7 @@ func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
|
||||
func authenticate(c *gin.Context) (Principal, *Token, bool) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Authentication == "disable" {
|
||||
return Principal{Username: "admin", Role: authn.RoleAdmin}, nil, true
|
||||
return Principal{Username: "admin", Role: authn.RoleAdmin, Permissions: authn.PermissionIDs()}, nil, true
|
||||
}
|
||||
|
||||
cookie, err := c.Cookie(CookieName)
|
||||
@@ -154,7 +213,11 @@ func authenticate(c *gin.Context) (Principal, *Token, bool) {
|
||||
log.Debugf("validate session for %q: %s", token.Username, err)
|
||||
return Principal{}, nil, false
|
||||
}
|
||||
return Principal{Username: user.Username, Role: user.Role}, token, true
|
||||
return Principal{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
Permissions: authn.EffectivePermissions(user),
|
||||
}, token, true
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context) {
|
||||
@@ -162,6 +225,11 @@ func abortUnauthorized(c *gin.Context) {
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func abortForbidden(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func GenerateJWT(username string, tokenVersion uint64) (string, error) {
|
||||
conf := config.GetInstance()
|
||||
now := time.Now()
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestParseJWTRejectsExpiredToken(t *testing.T) {
|
||||
func TestRevokeUserSessionsCancelsActiveRequests(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
unregister := activeSessions.register("alice", cancel)
|
||||
unregister := activeSessions.register("alice", cancel, newSessionHandle(), "")
|
||||
defer unregister()
|
||||
|
||||
RevokeUserSessions("alice")
|
||||
|
||||
@@ -15,21 +15,78 @@ import (
|
||||
|
||||
const SessionRevokedCloseCode = 4401
|
||||
|
||||
// SessionRefreshedCloseCode closes connections whose permissions changed while
|
||||
// the underlying login is still valid. Clients are expected to reconnect and
|
||||
// refresh their account state instead of returning to the login page.
|
||||
const SessionRefreshedCloseCode = 4403
|
||||
|
||||
type sessionHandleKey struct{}
|
||||
|
||||
// Long-lived connection channels, used to drop only the connections affected
|
||||
// by a permission change instead of every session of the user.
|
||||
const (
|
||||
SessionChannelConsole = "console" // /api/ws (input events, status pushes)
|
||||
SessionChannelStream = "stream" // /api/stream/* (video signaling and frames)
|
||||
SessionChannelTerminal = "terminal" // /api/vm/terminal
|
||||
SessionChannelAi = "ai" // /api/picoclaw/gateway/ws
|
||||
)
|
||||
|
||||
// SessionChannelForPath classifies long-lived endpoints; everything else
|
||||
// returns an empty channel and is only affected by hard revocation.
|
||||
func SessionChannelForPath(path string) string {
|
||||
switch {
|
||||
case path == "/api/ws":
|
||||
return SessionChannelConsole
|
||||
case strings.HasPrefix(path, "/api/stream/"):
|
||||
return SessionChannelStream
|
||||
case path == "/api/vm/terminal":
|
||||
return SessionChannelTerminal
|
||||
case path == "/api/picoclaw/gateway/ws":
|
||||
return SessionChannelAi
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// sessionHandle lets a revocation pick the WebSocket close code before the
|
||||
// request context is canceled.
|
||||
type sessionHandle struct {
|
||||
closeCode atomic.Int32
|
||||
}
|
||||
|
||||
type sessionEntry struct {
|
||||
cancel context.CancelFunc
|
||||
handle *sessionHandle
|
||||
channel string
|
||||
}
|
||||
|
||||
type sessionRegistry struct {
|
||||
mutex sync.Mutex
|
||||
nextID atomic.Uint64
|
||||
byUserID map[string]map[uint64]context.CancelFunc
|
||||
byUserID map[string]map[uint64]*sessionEntry
|
||||
}
|
||||
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]context.CancelFunc)}
|
||||
var activeSessions = &sessionRegistry{byUserID: make(map[string]map[uint64]*sessionEntry)}
|
||||
|
||||
func (r *sessionRegistry) register(username string, cancel context.CancelFunc) func() {
|
||||
func newSessionHandle() *sessionHandle {
|
||||
handle := &sessionHandle{}
|
||||
handle.closeCode.Store(SessionRevokedCloseCode)
|
||||
return handle
|
||||
}
|
||||
|
||||
// WithSessionHandle attaches the handle to the request context so
|
||||
// WatchWebSocket can honor the close code chosen at revocation time.
|
||||
func WithSessionHandle(ctx context.Context, handle *sessionHandle) context.Context {
|
||||
return context.WithValue(ctx, sessionHandleKey{}, handle)
|
||||
}
|
||||
|
||||
func (r *sessionRegistry) register(username string, cancel context.CancelFunc, handle *sessionHandle, channel string) func() {
|
||||
id := r.nextID.Add(1)
|
||||
r.mutex.Lock()
|
||||
if r.byUserID[username] == nil {
|
||||
r.byUserID[username] = make(map[uint64]context.CancelFunc)
|
||||
r.byUserID[username] = make(map[uint64]*sessionEntry)
|
||||
}
|
||||
r.byUserID[username][id] = cancel
|
||||
r.byUserID[username][id] = &sessionEntry{cancel: cancel, handle: handle, channel: channel}
|
||||
r.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
@@ -42,26 +99,79 @@ func (r *sessionRegistry) register(username string, cancel context.CancelFunc) f
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeUserSessions hard-drops every active session of the user. The login
|
||||
// is expected to be invalid afterwards; connections close as unauthorized.
|
||||
func RevokeUserSessions(username string) {
|
||||
dropUserSessions(username, SessionRevokedCloseCode, nil)
|
||||
}
|
||||
|
||||
// RefreshUserSessions drops the user's long-lived connections on the given
|
||||
// channels while their login stays valid, so those clients reconnect under
|
||||
// the updated permissions. Unrelated connections (e.g. the video stream when
|
||||
// only console control was revoked) stay untouched.
|
||||
func RefreshUserSessions(username string, channels ...string) {
|
||||
dropUserSessions(username, SessionRefreshedCloseCode, channels)
|
||||
}
|
||||
|
||||
func dropUserSessions(username string, closeCode int, channels []string) {
|
||||
activeSessions.mutex.Lock()
|
||||
sessions := activeSessions.byUserID[username]
|
||||
delete(activeSessions.byUserID, username)
|
||||
if channels == nil {
|
||||
delete(activeSessions.byUserID, username)
|
||||
}
|
||||
|
||||
dropped := make([]*sessionEntry, 0, len(sessions))
|
||||
for id, entry := range sessions {
|
||||
if !channelMatches(entry.channel, channels) {
|
||||
continue
|
||||
}
|
||||
dropped = append(dropped, entry)
|
||||
if channels != nil {
|
||||
delete(sessions, id)
|
||||
}
|
||||
}
|
||||
if channels != nil && len(sessions) == 0 {
|
||||
delete(activeSessions.byUserID, username)
|
||||
}
|
||||
activeSessions.mutex.Unlock()
|
||||
|
||||
for _, cancel := range sessions {
|
||||
cancel()
|
||||
for _, entry := range dropped {
|
||||
entry.handle.closeCode.Store(int32(closeCode))
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// channelMatches reports whether the entry is affected. A nil channel list
|
||||
// means a hard revocation, which drops everything.
|
||||
func channelMatches(channel string, channels []string) bool {
|
||||
if channels == nil {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range channels {
|
||||
if channel == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func WatchWebSocket(ctx context.Context, connection *websocket.Conn) func() {
|
||||
stopped := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
closeCode := SessionRevokedCloseCode
|
||||
reason := "session expired or revoked"
|
||||
if handle, ok := ctx.Value(sessionHandleKey{}).(*sessionHandle); ok {
|
||||
if code := int(handle.closeCode.Load()); code == SessionRefreshedCloseCode {
|
||||
closeCode = code
|
||||
reason = "permissions changed"
|
||||
}
|
||||
}
|
||||
_ = connection.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(SessionRevokedCloseCode, "session expired or revoked"),
|
||||
websocket.FormatCloseMessage(closeCode, reason),
|
||||
time.Now().Add(2*time.Second),
|
||||
)
|
||||
_ = connection.Close()
|
||||
|
||||
@@ -6,8 +6,9 @@ type LoginReq struct {
|
||||
}
|
||||
|
||||
type GetAccountRsp struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type ChangePasswordReq struct {
|
||||
@@ -20,10 +21,25 @@ type IsPasswordUpdatedRsp struct {
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
SystemAccount bool `json:"systemAccount,omitempty"`
|
||||
}
|
||||
|
||||
type ListPermissionsRsp struct {
|
||||
Permissions []PermissionInfo `json:"permissions"`
|
||||
}
|
||||
|
||||
// PermissionInfo describes one grantable permission: the group it belongs to,
|
||||
// how far it reaches, and the grants it implies. The client renders the editor
|
||||
// from this catalog instead of keeping its own copy of the model.
|
||||
type PermissionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Group string `json:"group"`
|
||||
Risk string `json:"risk"`
|
||||
Implies []string `json:"implies,omitempty"`
|
||||
}
|
||||
|
||||
type ListUsersRsp struct {
|
||||
@@ -31,12 +47,23 @@ type ListUsersRsp struct {
|
||||
}
|
||||
|
||||
type CreateUserReq struct {
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
Role string `json:"role" validate:"required"`
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
Role string `json:"role" validate:"required"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type UpdateUserReq struct {
|
||||
Role *string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Role *string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Permissions *[]string `json:"permissions"`
|
||||
// RotateMcpKey asks to regenerate the device-wide MCP API key when this
|
||||
// change takes the mcp grant away. The administrator decides: rotating
|
||||
// disconnects every MCP client, keeping it leaves a copied key working.
|
||||
RotateMcpKey bool `json:"rotateMcpKey"`
|
||||
}
|
||||
|
||||
// UpdateUserRsp says whether the change regenerated the MCP API key.
|
||||
type UpdateUserRsp struct {
|
||||
McpKeyRotated bool `json:"mcpKeyRotated"`
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ type StatusImageRsp struct {
|
||||
Status string `json:"status"`
|
||||
File string `json:"file"`
|
||||
Percentage string `json:"percentage"`
|
||||
Loaded int64 `json:"loaded"` // bytes transferred so far
|
||||
Total int64 `json:"total"` // expected bytes, 0 when unknown
|
||||
}
|
||||
|
||||
92
server/proto/media.go
Normal file
92
server/proto/media.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package proto
|
||||
|
||||
import "time"
|
||||
|
||||
// Virtual media wire types. A medium is named by a type plus a path relative
|
||||
// to the media directory: the server resolves the id itself, so a request
|
||||
// never carries an absolute path it could be talked into deleting.
|
||||
|
||||
const (
|
||||
MediaTypeImage = "image"
|
||||
MediaTypePartition = "partition"
|
||||
)
|
||||
|
||||
type MediaItem struct {
|
||||
Type string `json:"type"`
|
||||
Rel string `json:"rel"`
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
FS string `json:"fs"`
|
||||
Browsable bool `json:"browsable"`
|
||||
Attached bool `json:"attached"`
|
||||
Modified time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type MediaCaps struct {
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
type GetMediaRsp struct {
|
||||
Media []MediaItem `json:"media"`
|
||||
Caps MediaCaps `json:"capabilities"`
|
||||
}
|
||||
|
||||
type MediaStateRsp struct {
|
||||
State string `json:"state"` // none | image | partition | unsupported
|
||||
Type string `json:"type"` // type of the attached medium
|
||||
Rel string `json:"rel"` // rel of the attached medium, when it is an image
|
||||
HidOnly bool `json:"hidOnly"` // the gadget has no mass storage at all
|
||||
Open []string `json:"open"` // media the file manager currently holds open
|
||||
Cdrom bool `json:"cdrom"` // the attached medium is served as a CD-ROM
|
||||
}
|
||||
|
||||
type MediaRefReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
}
|
||||
|
||||
type MountMediaReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
Cdrom bool `json:"cdrom"`
|
||||
// Force re-enumerates the gadget to take the medium back from a target
|
||||
// machine that is holding it. It costs the target machine its unflushed
|
||||
// writes, so the UI asks before setting it.
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type EjectMediaReq struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type MediaStatusRsp struct {
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Percentage string `json:"percentage"`
|
||||
Loaded int64 `json:"loaded"` // bytes transferred so far
|
||||
Total int64 `json:"total"` // expected bytes, 0 when unknown
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type MediaFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Modified time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type GetMediaFilesRsp struct {
|
||||
Path string `json:"path"`
|
||||
Entries []MediaFile `json:"entries"`
|
||||
Free uint64 `json:"free"`
|
||||
Total uint64 `json:"total"`
|
||||
// Writing is true while the medium is seeing block level writes: a zero
|
||||
// length entry in that state is a transfer in progress, not an empty file.
|
||||
Writing bool `json:"writing"`
|
||||
}
|
||||
|
||||
type MediaPathReq struct {
|
||||
Type string `json:"type" validate:"omitempty,oneof=image partition"`
|
||||
Rel string `json:"rel"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
}
|
||||
@@ -72,6 +72,10 @@ type GetVirtualDeviceRsp struct {
|
||||
|
||||
type UpdateVirtualDeviceReq struct {
|
||||
Device string `validate:"required"`
|
||||
// Enable turns the device on or off instead of toggling it. A caller that
|
||||
// says "enable" must not switch a running device off because it happened to
|
||||
// be already on.
|
||||
Enable *bool `json:"enable"`
|
||||
}
|
||||
|
||||
type UpdateVirtualDeviceRsp struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func applicationRouter(r *gin.Engine) {
|
||||
service := application.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermUpdate))
|
||||
|
||||
api.GET("/application/version", service.GetVersion) // get application version
|
||||
api.POST("/application/update", service.Update) // update application
|
||||
|
||||
@@ -24,6 +24,7 @@ func authRouter(r *gin.Engine) {
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.GET("/auth/permissions", service.ListPermissions)
|
||||
admin.GET("/auth/users", service.ListUsers)
|
||||
admin.POST("/auth/users", service.CreateUser)
|
||||
admin.PUT("/auth/users/:username", service.UpdateUser)
|
||||
|
||||
@@ -22,7 +22,7 @@ type setAIControlModeRequest struct {
|
||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
group := r.Group("/api/ai/control").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := control.Status()
|
||||
|
||||
@@ -2,19 +2,25 @@ package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/service/download"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/download"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func downloadRouter(r *gin.Engine) {
|
||||
service := download.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
// A signed URL that ends up on the data partition changes what this device
|
||||
// keeps, so it follows the library write grant.
|
||||
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
|
||||
// Progress and availability are reads: a user who may attach a medium can
|
||||
// take /data away from the downloader, so they need to see whether one is
|
||||
// running, and reading the library is implied by every media grant.
|
||||
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
|
||||
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||
api.GET("/download/image/status", service.StatusImage) // download image
|
||||
api.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
api.POST("/download/file", service.DownloadImageFile) // download image
|
||||
write.POST("/download/image", service.DownloadImage) // download image
|
||||
write.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||
read.GET("/download/image/status", service.StatusImage) // download image
|
||||
read.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
write.POST("/download/file", service.DownloadImageFile) // upload image file
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func extensionsRouter(r *gin.Engine) {
|
||||
api := r.Group("/api/extensions").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermTailscale),
|
||||
)
|
||||
|
||||
ts := tailscale.NewService()
|
||||
|
||||
@@ -16,26 +16,24 @@ func HIDLoopbackHTTPAllowedPaths() []string {
|
||||
|
||||
func hidRouter(r *gin.Engine) {
|
||||
service := hid.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
view := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
control := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleControl))
|
||||
system := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermDevice))
|
||||
localAPI := r.Group("/api/internal").Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
control.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||
view.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
view.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // get shortcut leader key
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
view.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
view.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
admin.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
admin.POST("/hid/shortcut", service.AddShortcut)
|
||||
admin.DELETE("/hid/shortcut", service.DeleteShortcut)
|
||||
admin.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
|
||||
system.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
system.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
system.POST("/hid/shortcut", service.AddShortcut)
|
||||
system.DELETE("/hid/shortcut", service.DeleteShortcut)
|
||||
system.POST("/hid/shortcut/leader-key", service.SetLeaderKey)
|
||||
|
||||
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
@@ -24,9 +25,11 @@ func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *pic
|
||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
||||
},
|
||||
)
|
||||
auth.APIKeyRotator = mcpservice.RotateAPIKey
|
||||
|
||||
management := r.Group("/api/mcp").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermMcp),
|
||||
)
|
||||
management.GET("/config", service.GetConfig)
|
||||
management.POST("/config", service.SetConfig)
|
||||
|
||||
@@ -14,21 +14,18 @@ func networkRouter(r *gin.Engine) {
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
power := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermPower))
|
||||
network := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermNetwork))
|
||||
|
||||
api.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
api.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||
power.POST("/network/wol", service.WakeOnLAN) // wake on lan
|
||||
power.GET("/network/wol/mac", service.GetMac) // get mac list
|
||||
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
admin.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
admin.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
power.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
|
||||
power.POST("/network/wol/mac/name", service.SetMacName) // set mac name
|
||||
|
||||
admin.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
admin.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
admin.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
admin.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
admin.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
network.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
|
||||
network.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
|
||||
network.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
|
||||
network.GET("/network/dns", service.GetDNS) // get DNS configuration
|
||||
network.POST("/network/dns", service.SetDNS) // set DNS configuration
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
middleware.RequirePermission(authn.PermAi),
|
||||
)
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/auth"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
"NanoKVM-Server/service/ws"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,6 +37,8 @@ func server(r *gin.Engine) {
|
||||
control := controlmode.GetManager()
|
||||
picoclawService := picoclaw.NewService(control)
|
||||
|
||||
auth.SessionNotifier = ws.GetManager().NotifyUser
|
||||
|
||||
authRouter(r)
|
||||
applicationRouter(r)
|
||||
vmRouter(r)
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func storageRouter(r *gin.Engine) {
|
||||
service := storage.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequireRole(authn.RoleAdmin))
|
||||
// Reading the library is the one grant the other media grants imply, so it
|
||||
// alone gates every read: it is what makes the storage page reachable.
|
||||
read := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryRead))
|
||||
// Attaching a medium is what hands it to the target machine; changing the
|
||||
// library is what changes what this device keeps.
|
||||
attach := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermMediumAttach))
|
||||
write := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermLibraryWrite))
|
||||
// The file handlers serve an image and the shared partition through the same
|
||||
// path: the first is library content, the second is the surface the target
|
||||
// machine reads. Which grant applies follows the medium in the request, so
|
||||
// the route admits either and the handler narrows the pair.
|
||||
files := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequirePermission(authn.PermLibraryWrite, authn.PermMediumAttach),
|
||||
)
|
||||
|
||||
api.GET("/storage/image", service.GetImages) // get image list
|
||||
api.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||
api.POST("/storage/image/mount", service.MountImage) // mount image
|
||||
api.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
|
||||
api.POST("/storage/image/delete", service.DeleteImage) // delete image
|
||||
// Virtual media: the disk images the emulated USB disk exposes, the files
|
||||
// inside them, and the shared partition the target machine sees.
|
||||
read.GET("/storage/media", service.GetMedia) // list media
|
||||
read.GET("/storage/media/state", service.GetMediaState) // what the target machine sees
|
||||
read.GET("/storage/media/status", service.GetMediaStatus) // transfer progress
|
||||
read.GET("/storage/media/download", service.DownloadMedia) // download a whole image
|
||||
read.GET("/storage/media/files", service.ListMediaFiles) // list files inside a medium
|
||||
read.GET("/storage/media/files/download", service.DownloadMediaFile)
|
||||
attach.POST("/storage/media/mount", service.MountMedia) // attach a medium to the target machine
|
||||
attach.POST("/storage/media/eject", service.EjectMedia) // release the medium
|
||||
write.POST("/storage/media/delete", service.DeleteMedia) // delete a medium
|
||||
files.POST("/storage/media/files/upload", service.UploadMediaFile)
|
||||
files.POST("/storage/media/files/delete", service.DeleteMediaFile)
|
||||
|
||||
// The pre-2.6 image API, kept for scripts written against it.
|
||||
read.GET("/storage/image", service.GetImages) // get image list
|
||||
read.GET("/storage/image/mounted", service.GetMountedImage) // get mounted image
|
||||
read.GET("/storage/cdrom", service.GetCdRom) // get CD-ROM flag
|
||||
attach.POST("/storage/image/mount", service.MountImage) // mount image
|
||||
write.POST("/storage/image/delete", service.DeleteImage) // delete image
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/stream/direct"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func streamRouter(r *gin.Engine) {
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
|
||||
api.GET("/stream/mjpeg", mjpeg.Connect) // mjpeg stream
|
||||
api.POST("/stream/mjpeg/detect", mjpeg.UpdateFrameDetect) // update frame detect
|
||||
|
||||
@@ -11,72 +11,95 @@ import (
|
||||
func vmRouter(r *gin.Engine) {
|
||||
service := vm.NewService()
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
admin := r.Group("/api").Use(
|
||||
middleware.CheckToken(),
|
||||
middleware.RequireRole(authn.RoleAdmin),
|
||||
)
|
||||
use := func(permission authn.Permission) gin.IRoutes {
|
||||
return r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(permission))
|
||||
}
|
||||
// The app shell reads the site title on every page, including the pages an
|
||||
// account without console access can open, so it asks for no feature grant.
|
||||
base := r.Group("/api").Use(middleware.CheckToken())
|
||||
view := use(authn.PermConsoleView)
|
||||
control := use(authn.PermConsoleControl)
|
||||
power := use(authn.PermPower)
|
||||
terminal := use(authn.PermTerminal)
|
||||
device := use(authn.PermDevice)
|
||||
network := use(authn.PermNetwork)
|
||||
|
||||
api.GET("/vm/info", service.GetInfo) // get device information
|
||||
api.GET("/vm/hardware", service.GetHardware) // get hardware version
|
||||
view.GET("/vm/info", service.GetInfo) // get device information
|
||||
view.GET("/vm/hardware", service.GetHardware) // get hardware version
|
||||
|
||||
api.POST("/vm/gpio", service.SetGpio) // update gpio
|
||||
api.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
api.POST("/vm/screen", service.SetScreen) // update screen
|
||||
power.POST("/vm/gpio", service.SetGpio) // update gpio
|
||||
power.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
|
||||
api.GET("/vm/input-region", service.GetInputRegion)
|
||||
api.POST("/vm/input-region", service.SetInputRegion)
|
||||
api.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
// Video encoding and capture recovery are view operations: a user who may
|
||||
// only watch still has to be able to pick a quality their link can carry,
|
||||
// and every account holds console.view. Mapping pointer motion onto the
|
||||
// target screen stays console.control.
|
||||
view.POST("/vm/screen", service.SetScreen) // update screen
|
||||
|
||||
admin.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
view.GET("/vm/input-region", service.GetInputRegion)
|
||||
control.POST("/vm/input-region", service.SetInputRegion)
|
||||
view.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
|
||||
admin.GET("/vm/script", service.GetScripts) // get script
|
||||
admin.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
admin.POST("/vm/script/run", service.RunScript) // run script
|
||||
admin.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
terminal.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
|
||||
admin.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
admin.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
terminal.GET("/vm/script", service.GetScripts) // get script
|
||||
terminal.POST("/vm/script/upload", service.UploadScript) // upload script
|
||||
terminal.POST("/vm/script/run", service.RunScript) // run script
|
||||
terminal.DELETE("/vm/script", service.DeleteScript) // delete script
|
||||
|
||||
admin.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
admin.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
// The emulated USB devices are device settings, not media or network ones:
|
||||
// the virtual disk hands the raw data partition to the target machine and the
|
||||
// RNDIS adapter hands it a network interface, so neither belongs to the
|
||||
// media library the disk serves or to the network settings of this side.
|
||||
device.GET("/vm/device/virtual", service.GetVirtualDevice) // get virtual device
|
||||
device.POST("/vm/device/virtual", service.UpdateVirtualDevice) // update virtual device
|
||||
|
||||
admin.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
admin.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
device.GET("/vm/memory/limit", service.GetMemoryLimit) // get memory limit
|
||||
device.POST("/vm/memory/limit", service.SetMemoryLimit) // set memory limit
|
||||
|
||||
device.GET("/vm/oled", service.GetOLED) // get OLED configuration
|
||||
device.POST("/vm/oled", service.SetOLED) // set OLED configuration
|
||||
|
||||
// Only supported by PCIe version
|
||||
api.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
admin.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
admin.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
admin.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
//
|
||||
// Toggling the HDMI capture engine is a device setting, but resetting it is
|
||||
// capture recovery: a watcher whose screen went black needs it to see
|
||||
// anything at all, the same way it needs to pick a video mode and a quality.
|
||||
// Enabling, disabling and the idle timeout stay with the device grant, since
|
||||
// they change how the device behaves for everyone rather than recover a
|
||||
// broken picture.
|
||||
view.GET("/vm/hdmi", service.GetHdmiState) // get HDMI state
|
||||
view.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
device.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
device.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
device.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
|
||||
admin.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
admin.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
admin.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||
device.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
device.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
device.POST("/vm/ssh/disable", service.DisableSSH) // disable SSH
|
||||
|
||||
admin.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
admin.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||
device.GET("/vm/swap", service.GetSwap) // get swap file size
|
||||
device.POST("/vm/swap", service.SetSwap) // set swap file size
|
||||
|
||||
admin.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
admin.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
||||
device.GET("/vm/mouse-jiggler", service.GetMouseJiggler) // get mouse jiggler
|
||||
device.POST("/vm/mouse-jiggler/", service.SetMouseJiggler) // set mouse jiggler
|
||||
|
||||
api.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||
admin.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
||||
view.GET("/vm/hostname", service.GetHostname) // Get Hostname
|
||||
device.POST("/vm/hostname", service.SetHostname) // Set Hostname
|
||||
|
||||
api.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||
admin.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
||||
base.GET("/vm/web-title", service.GetWebTitle) // Get web title
|
||||
device.POST("/vm/web-title", service.SetWebTitle) // Set web title
|
||||
|
||||
admin.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
admin.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
admin.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
device.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
|
||||
device.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
|
||||
device.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
|
||||
|
||||
admin.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
network.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
admin.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
admin.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
admin.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
admin.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
terminal.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
terminal.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
terminal.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
terminal.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
|
||||
admin.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
device.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package router
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/ws"
|
||||
)
|
||||
|
||||
func wsRouter(r *gin.Engine) {
|
||||
service := ws.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
api := r.Group("/api").Use(middleware.CheckToken(), middleware.RequirePermission(authn.PermConsoleView))
|
||||
|
||||
api.GET("/ws", service.Connect)
|
||||
}
|
||||
|
||||
@@ -98,8 +98,9 @@ func (s *Service) GetAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.GetAccountRsp{
|
||||
Username: principal.Username,
|
||||
Role: string(principal.Role),
|
||||
Username: principal.Username,
|
||||
Role: string(principal.Role),
|
||||
Permissions: permissionStrings(principal.Permissions),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,42 @@ import (
|
||||
|
||||
var errInvalidPassword = errors.New("invalid password")
|
||||
|
||||
// SessionNotifier pushes an account event to a user's live connections. It is
|
||||
// wired to the websocket manager during router setup; kept as a hook so this
|
||||
// package does not depend on the cgo-based HID stack.
|
||||
var SessionNotifier func(username, event, data string)
|
||||
|
||||
// APIKeyRotator replaces the device-wide MCP API key. It is wired by the MCP
|
||||
// router for the same reason as SessionNotifier.
|
||||
var APIKeyRotator func() error
|
||||
|
||||
// rotateAPIKeyIfLost regenerates the MCP API key when the account no longer
|
||||
// reaches it and the administrator asked for it. The key outlives the web
|
||||
// session, so a key the account copied keeps working until it is replaced;
|
||||
// replacing it disconnects every MCP client, so the account editor warns and
|
||||
// leaves the choice to the administrator. A failed rotation is reported so the
|
||||
// administrator learns that the old key still works.
|
||||
func rotateAPIKeyIfLost(previous, current []authn.Permission, requested bool) (bool, error) {
|
||||
if !authn.Has(previous, authn.PermMcp) || authn.Has(current, authn.PermMcp) {
|
||||
return false, nil
|
||||
}
|
||||
if !requested {
|
||||
log.Warnf("mcp grant revoked; the MCP API key was kept at the administrator's request")
|
||||
return false, nil
|
||||
}
|
||||
if APIKeyRotator == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err := APIKeyRotator(); err != nil {
|
||||
log.Errorf("rotate MCP API key after revoking the mcp grant: %s", err)
|
||||
return false, errAPIKeyNotRotated
|
||||
}
|
||||
log.Infof("MCP API key rotated after the mcp grant was revoked")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var errAPIKeyNotRotated = errors.New("the change was saved, but the MCP API key could not be regenerated; regenerate it from the MCP settings")
|
||||
|
||||
func (s *Service) ListUsers(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
users, err := authn.DefaultStore.List()
|
||||
@@ -27,12 +63,28 @@ func (s *Service) ListUsers(c *gin.Context) {
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
Enabled: user.Enabled,
|
||||
Permissions: permissionStrings(user.Permissions),
|
||||
SystemAccount: user.SystemAccount,
|
||||
})
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ListUsersRsp{Users: result})
|
||||
}
|
||||
|
||||
func (s *Service) ListPermissions(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
catalog := authn.Catalog()
|
||||
permissions := make([]proto.PermissionInfo, 0, len(catalog))
|
||||
for _, descriptor := range catalog {
|
||||
permissions = append(permissions, proto.PermissionInfo{
|
||||
ID: string(descriptor.Permission),
|
||||
Group: string(descriptor.Group),
|
||||
Risk: string(descriptor.Risk),
|
||||
Implies: permissionStrings(descriptor.Implies),
|
||||
})
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ListPermissionsRsp{Permissions: permissions})
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(c *gin.Context) {
|
||||
var req proto.CreateUserReq
|
||||
var rsp proto.Response
|
||||
@@ -45,10 +97,15 @@ func (s *Service) CreateUser(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role)); err != nil {
|
||||
permissions, err := parsePermissions(req.Permissions)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, err.Error())
|
||||
return
|
||||
}
|
||||
if err = authn.DefaultStore.Create(req.Username, password, authn.Role(req.Role), permissions...); err != nil {
|
||||
rsp.ErrRsp(c, -4, err.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRsp(c)
|
||||
log.Infof("user created: %s (%s)", req.Username, req.Role)
|
||||
}
|
||||
@@ -56,7 +113,7 @@ func (s *Service) CreateUser(c *gin.Context) {
|
||||
func (s *Service) UpdateUser(c *gin.Context) {
|
||||
var req proto.UpdateUserReq
|
||||
var rsp proto.Response
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil) {
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil || (req.Role == nil && req.Enabled == nil && req.Permissions == nil) {
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
return
|
||||
}
|
||||
@@ -66,13 +123,58 @@ func (s *Service) UpdateUser(c *gin.Context) {
|
||||
role := authn.Role(*req.Role)
|
||||
patch.Role = &role
|
||||
}
|
||||
if req.Permissions != nil {
|
||||
permissions, err := parsePermissions(*req.Permissions)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
patch.Permissions = &permissions
|
||||
}
|
||||
username := c.Param("username")
|
||||
if _, err := authn.DefaultStore.Update(principal.Username, username, patch); err != nil {
|
||||
// The store hands back the account as it was before and after this patch,
|
||||
// read under one lock, so a role change is handled exactly like editing the
|
||||
// permission set: the role resolves to its implicit grants and the
|
||||
// difference drives the refresh.
|
||||
previous, updated, err := authn.DefaultStore.Update(principal.Username, username, patch)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
// A disabled account reaches nothing, whatever it stores.
|
||||
previousPermissions := enabledPermissions(previous)
|
||||
currentPermissions := enabledPermissions(updated)
|
||||
rotated, rotateErr := rotateAPIKeyIfLost(previousPermissions, currentPermissions, req.RotateMcpKey)
|
||||
|
||||
if updated.TokenVersion != previous.TokenVersion {
|
||||
// Disabling the account is the only change that invalidates the login.
|
||||
middleware.RevokeUserSessions(username)
|
||||
} else {
|
||||
// Every other change keeps the login valid: drop only the long-lived
|
||||
// connections whose in-memory state depends on what changed, and let the
|
||||
// frontend refresh its account state in place. Revoked grants have to
|
||||
// reconnect to stop using the feature, and granted ones have to reconnect
|
||||
// to pick up capabilities cached at connect time (the console socket
|
||||
// remembers whether it is read-only). Unrelated connections stay
|
||||
// untouched. A save that changes nothing stays silent.
|
||||
changed := permissionsChanged(previousPermissions, currentPermissions)
|
||||
roleChanged := updated.Role != previous.Role
|
||||
if (roleChanged || len(changed) > 0) && SessionNotifier != nil {
|
||||
// The role itself is rendered too (user management, the role label),
|
||||
// so the frontend refreshes even when the grants happen to match.
|
||||
// The notifier writes to every socket of the user, each with its own
|
||||
// write deadline; one stuck connection must not hold this request.
|
||||
go SessionNotifier(username, "auth", "refresh")
|
||||
}
|
||||
if channels := channelsForPermissions(changed); len(channels) > 0 {
|
||||
middleware.RefreshUserSessions(username, channels...)
|
||||
}
|
||||
}
|
||||
if rotateErr != nil {
|
||||
rsp.ErrRsp(c, -3, rotateErr.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
|
||||
log.Infof("user updated: %s", username)
|
||||
}
|
||||
|
||||
@@ -80,12 +182,19 @@ func (s *Service) DeleteUser(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
principal, _ := middleware.CurrentPrincipal(c)
|
||||
username := c.Param("username")
|
||||
previous, _ := authn.DefaultStore.Get(username)
|
||||
if err := authn.DefaultStore.Delete(principal.Username, username); err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
middleware.RevokeUserSessions(username)
|
||||
rsp.OkRsp(c)
|
||||
// DELETE carries no body, so the choice travels in the query string.
|
||||
rotated, err := rotateAPIKeyIfLost(enabledPermissions(previous), nil, c.Query("rotateMcpKey") == "true")
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, err.Error())
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.UpdateUserRsp{McpKeyRotated: rotated})
|
||||
log.Infof("user deleted: %s", username)
|
||||
}
|
||||
|
||||
@@ -115,6 +224,77 @@ func (s *Service) ChangeUserPassword(c *gin.Context) {
|
||||
log.Infof("password reset for user: %s", username)
|
||||
}
|
||||
|
||||
func enabledPermissions(user *authn.User) []authn.Permission {
|
||||
if user == nil || !user.Enabled {
|
||||
return nil
|
||||
}
|
||||
return authn.EffectivePermissions(user)
|
||||
}
|
||||
|
||||
// permissionsChanged returns the grants that were added or removed. Both
|
||||
// directions matter: a revoked grant must stop reaching the feature, while a
|
||||
// granted one must reconnect to pick up state cached per connection.
|
||||
func permissionsChanged(oldPermissions, newPermissions []authn.Permission) []authn.Permission {
|
||||
changed := make([]authn.Permission, 0, len(oldPermissions))
|
||||
for _, old := range oldPermissions {
|
||||
if !authn.Has(newPermissions, old) {
|
||||
changed = append(changed, old)
|
||||
}
|
||||
}
|
||||
for _, current := range newPermissions {
|
||||
if !authn.Has(oldPermissions, current) {
|
||||
changed = append(changed, current)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// channelsForPermissions maps permissions that changed to the long-lived
|
||||
// connection channels that must reconnect to take effect. Permissions without
|
||||
// connection state (library, network, ...) are enforced per-request and need no
|
||||
// teardown.
|
||||
func channelsForPermissions(permissions []authn.Permission) []string {
|
||||
channels := make([]string, 0, 2)
|
||||
for _, permission := range permissions {
|
||||
switch permission {
|
||||
case authn.PermConsoleView:
|
||||
// Losing the console grant closes the video stream, and gaining it (an
|
||||
// account promoted to administrator) keeps every console connection
|
||||
// from running under the grants it cached at connect time.
|
||||
channels = append(channels, middleware.SessionChannelConsole, middleware.SessionChannelStream)
|
||||
case authn.PermConsoleControl:
|
||||
channels = append(channels, middleware.SessionChannelConsole)
|
||||
case authn.PermTerminal:
|
||||
channels = append(channels, middleware.SessionChannelTerminal)
|
||||
case authn.PermAi:
|
||||
channels = append(channels, middleware.SessionChannelAi)
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func parsePermissions(raw []string) ([]authn.Permission, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
permissions := make([]authn.Permission, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
permissions = append(permissions, authn.Permission(item))
|
||||
}
|
||||
if err := authn.ValidatePermissions(permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
func permissionStrings(permissions []authn.Permission) []string {
|
||||
strings := make([]string, 0, len(permissions))
|
||||
for _, permission := range permissions {
|
||||
strings = append(strings, string(permission))
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
func decodePassword(encrypted string) (string, error) {
|
||||
password, err := utils.DecodeDecrypt(encrypted)
|
||||
if err != nil || password == "" {
|
||||
|
||||
53
server/service/auth/users_test.go
Normal file
53
server/service/auth/users_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
)
|
||||
|
||||
func TestRotateAPIKeyOnlyWhenLostAndRequested(t *testing.T) {
|
||||
calls := 0
|
||||
failing := false
|
||||
original := APIKeyRotator
|
||||
APIKeyRotator = func() error {
|
||||
calls++
|
||||
if failing {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { APIKeyRotator = original }()
|
||||
|
||||
withMcp := []authn.Permission{authn.PermConsoleView, authn.PermMcp}
|
||||
without := []authn.Permission{authn.PermConsoleView}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
previous, current []authn.Permission
|
||||
requested bool
|
||||
wantRotated bool
|
||||
}{
|
||||
{"kept grant", withMcp, withMcp, true, false},
|
||||
{"never held", without, without, true, false},
|
||||
{"lost, kept by request", withMcp, without, false, false},
|
||||
{"lost, rotation requested", withMcp, without, true, true},
|
||||
{"deleted, rotation requested", withMcp, nil, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
calls = 0
|
||||
rotated, err := rotateAPIKeyIfLost(tc.previous, tc.current, tc.requested)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rotated != tc.wantRotated || calls != map[bool]int{false: 0, true: 1}[tc.wantRotated] {
|
||||
t.Fatalf("%s: rotated=%v calls=%d", tc.name, rotated, calls)
|
||||
}
|
||||
}
|
||||
|
||||
failing = true
|
||||
if rotated, err := rotateAPIKeyIfLost(withMcp, without, true); rotated || !errors.Is(err, errAPIKeyNotRotated) {
|
||||
t.Fatalf("failed rotation: rotated=%v err=%v", rotated, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -14,10 +16,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -37,12 +37,12 @@ const (
|
||||
|
||||
imageDirectory = "/data"
|
||||
|
||||
// ISO 9660 and UDF volume descriptors live in sector 16 of the image.
|
||||
volumeDescriptorOffset = 0x8001
|
||||
volumeDescriptorSize = 5
|
||||
// Only the descriptors are read before the image is stored, so an unusable
|
||||
// image is rejected without transferring all of it.
|
||||
imageHeaderSize = volumeDescriptorOffset + volumeDescriptorSize
|
||||
// transferTempPrefix marks the partial files of the uploads and downloads
|
||||
// this service writes. The file manager hides them and refuses to delete
|
||||
// them while a transfer runs (diskfs.IsTransferTemp), and a restart sweeps
|
||||
// whatever a transfer left behind.
|
||||
transferTempPrefix = diskfs.DownloadTempPrefix
|
||||
|
||||
// Keep /data from filling up completely: the exFAT metadata and the USB
|
||||
// mass-storage export still need room while the image is written.
|
||||
imageFreeReserve = 128 << 20
|
||||
@@ -51,26 +51,75 @@ const (
|
||||
var (
|
||||
errDownloadInProgress = errors.New("download in progress")
|
||||
errSHA256Mismatch = errors.New("sha256 mismatch")
|
||||
errUnsupportedImage = errors.New("unsupported image format: expected an ISO 9660/UDF image or a raw disk image")
|
||||
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
)
|
||||
|
||||
// maxUploadBytes caps a single upload even when the client lies about its
|
||||
// length, so a runaway request cannot fill the card before the free space
|
||||
// check has had its say.
|
||||
const maxUploadBytes = 8 << 30
|
||||
|
||||
type Service struct {
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadPercentage string
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadLoaded int64
|
||||
downloadTotal int64
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
// A running transfer cannot survive a server restart, so any remaining lock
|
||||
// file is stale at this point.
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
go removeStaleTransfers()
|
||||
|
||||
return &Service{downloadStatus: downloadStatusIdle}
|
||||
}
|
||||
|
||||
// removeStaleTransfers deletes the temporary files of uploads and downloads
|
||||
// that a restart interrupted. They are hidden and can be hundreds of
|
||||
// megabytes, and hidden from the file manager, so leaving them behind would
|
||||
// quietly fill the card. A file upload into the shared folder
|
||||
// writes its temporary next to its target, so the whole tree is walked, not
|
||||
// just the root.
|
||||
//
|
||||
// The walk covers the whole data partition, so it runs in the background, and
|
||||
// a transfer may start while it does: only names one of our transfers would
|
||||
// generate are collected, and each is removed only while no transfer runs. A
|
||||
// transfer started after the walk writes a fresh name the walk never saw.
|
||||
// While the target machine owns the partition it is read-only here, and the
|
||||
// sweep waits for the next start.
|
||||
func removeStaleTransfers() {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &stat); err != nil || stat.Flags&unix.ST_RDONLY != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var stale []string
|
||||
_ = filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if diskfs.IsTransferTemp(info.Name()) {
|
||||
stale = append(stale, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
for _, path := range stale {
|
||||
if storage.TransferInProgress() {
|
||||
log.Infof("leave stale transfer %s: a transfer is running", path)
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Errorf("remove stale transfer %s failed: %s", path, err)
|
||||
} else {
|
||||
log.Infof("removed stale transfer %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CancelDownloadImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -122,7 +171,8 @@ func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan st
|
||||
s.downloadDone = done
|
||||
s.downloadStatus = downloadStatusInProgress
|
||||
s.downloadFile = file
|
||||
s.downloadPercentage = ""
|
||||
s.downloadLoaded = 0
|
||||
s.downloadTotal = 0
|
||||
|
||||
return done, nil
|
||||
}
|
||||
@@ -136,12 +186,13 @@ func (s *Service) setDownloadFile(done chan struct{}, file string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, loaded, total int64) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadPercentage = percentage
|
||||
s.downloadLoaded = loaded
|
||||
s.downloadTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +207,8 @@ func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
|
||||
s.downloadDone = nil
|
||||
s.downloadStatus = status
|
||||
s.downloadFile = ""
|
||||
s.downloadPercentage = ""
|
||||
s.downloadLoaded = 0
|
||||
s.downloadTotal = 0
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
@@ -178,49 +230,6 @@ func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
|
||||
}
|
||||
|
||||
// isSupportedImage reports whether the uploaded image can be exposed through
|
||||
// the USB mass-storage gadget. ISO 9660 images and UDF bridges, which is what
|
||||
// bootable installer ISOs are normally built as, carry "CD001" in the volume
|
||||
// descriptor sector, while UDF-only images carry "BEA01" there.
|
||||
func isSupportedImage(head []byte) bool {
|
||||
if len(head) >= volumeDescriptorOffset+volumeDescriptorSize {
|
||||
switch string(head[volumeDescriptorOffset : volumeDescriptorOffset+volumeDescriptorSize]) {
|
||||
case "CD001", "BEA01":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return looksLikeDiskImage(head)
|
||||
}
|
||||
|
||||
// looksLikeDiskImage reports whether the image starts with a partition table or
|
||||
// a filesystem that a target machine can mount on its own, which covers raw
|
||||
// disk images such as macOS installers converted from a DMG.
|
||||
func looksLikeDiskImage(head []byte) bool {
|
||||
if len(head) < 0x200 {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(head) >= 0x208 && string(head[0x200:0x208]) == "EFI PART" { // GPT header
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x24 && string(head[0x20:0x24]) == "NXSB" { // APFS container superblock
|
||||
return true
|
||||
}
|
||||
|
||||
if len(head) >= 0x402 {
|
||||
switch string(head[0x400:0x402]) { // HFS+/HFS volume header
|
||||
case "H+", "BD":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MBR partition table. FAT, exFAT and NTFS volume images carry the same
|
||||
// signature in their boot sector.
|
||||
return head[0x1FE] == 0x55 && head[0x1FF] == 0xAA
|
||||
}
|
||||
|
||||
// ensureImageSpace rejects an upload that cannot fit into dir, keeping a small
|
||||
// reserve for filesystem metadata and for the USB mass-storage export.
|
||||
func ensureImageSpace(dir string, contentLength int64) error {
|
||||
@@ -247,19 +256,42 @@ func ensureImageSpace(dir string, contentLength int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageSpaceLimit is how many bytes a download may write into dir: its
|
||||
// announced length, which ensureImageSpace has already checked, or else what
|
||||
// fits while keeping the reserve.
|
||||
func imageSpaceLimit(dir string, contentLength int64) (int64, error) {
|
||||
if contentLength >= 0 {
|
||||
return contentLength, nil
|
||||
}
|
||||
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(dir, &stat); err != nil {
|
||||
return 0, fmt.Errorf("cannot read free space of %s: %w", dir, err)
|
||||
}
|
||||
|
||||
available := uint64(stat.Bavail) * uint64(stat.Bsize)
|
||||
if available <= imageFreeReserve {
|
||||
return 0, fmt.Errorf("not enough space in %s: %d MiB available", dir, available>>20)
|
||||
}
|
||||
return int64(available - imageFreeReserve), nil
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
s.downloadMutex.Lock()
|
||||
status := s.downloadStatus
|
||||
file := s.downloadFile
|
||||
percentage := s.downloadPercentage
|
||||
loaded := s.downloadLoaded
|
||||
total := s.downloadTotal
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: string(status),
|
||||
File: file,
|
||||
Percentage: percentage,
|
||||
Percentage: utils.FormatPercentage(loaded, total),
|
||||
Loaded: loaded,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -267,6 +299,18 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImageFile")
|
||||
|
||||
// Everything below lands on the data partition, which the target machine
|
||||
// owns whenever the emulated disk is attached to it. Hold that ownership
|
||||
// decision for the whole transfer: a mount must wait instead of remounting
|
||||
// /data underneath an upload that is already streaming.
|
||||
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
defer releaseDataPartitionWrite()
|
||||
|
||||
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
@@ -290,6 +334,11 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
s.finishDownload(done, finalStatus)
|
||||
}()
|
||||
|
||||
// The whole body is written to the data partition, so cap the request even
|
||||
// when the client lies about its length. This has to happen before the
|
||||
// multipart reader is created: it keeps reading from the body it was handed.
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadBytes)
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
@@ -313,7 +362,8 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
if err := validateImageFilename(filename); err != nil {
|
||||
_ = part.Close()
|
||||
log.Warnf("reject uploaded image %q: %s", filename, err)
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
@@ -322,7 +372,7 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
|
||||
// The rejection paths below return without draining the request body: the
|
||||
// client stops uploading as soon as the response arrives.
|
||||
out, err := os.CreateTemp(imageDirectory, ".nanokvm-upload-*")
|
||||
out, err := os.CreateTemp(imageDirectory, transferTempPrefix+"upload-*")
|
||||
if err != nil {
|
||||
log.Errorf("cannot create temporary file in %s: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -1, "cannot create temporary file")
|
||||
@@ -332,33 +382,18 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
lw := utils.NewProgressWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(loaded, total int64) {
|
||||
s.setDownloadProgress(done, loaded, total)
|
||||
})
|
||||
defer lw.stopTicker()
|
||||
|
||||
// Check the volume descriptors while streaming, so an unusable image is
|
||||
// rejected in seconds instead of after the whole upload.
|
||||
head := make([]byte, imageHeaderSize)
|
||||
headSize, readErr := io.ReadFull(part, head)
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||
log.Errorf("read uploaded image %q failed: %s", filename, readErr)
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
if !isSupportedImage(head[:headSize]) {
|
||||
log.Warnf("reject uploaded image %q: %s", filename, errUnsupportedImage)
|
||||
rsp.ErrRsp(c, -1, errUnsupportedImage.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, copyErr := lw.Write(head[:headSize])
|
||||
if copyErr == nil {
|
||||
_, copyErr = io.Copy(lw, part)
|
||||
}
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, closeErr))
|
||||
// The name already said what this has to be: .iso or .img. The bytes
|
||||
// are taken as they come; a file that turns out not to be an image
|
||||
// fails when it is mounted, not while it is stored.
|
||||
_, copyErr := io.Copy(lw, part)
|
||||
lw.Stop()
|
||||
partCloseErr := part.Close()
|
||||
outCloseErr := out.Close()
|
||||
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
|
||||
log.Errorf("write uploaded image %q failed: %s", filename, errors.Join(copyErr, partCloseErr, outCloseErr))
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
return
|
||||
}
|
||||
@@ -370,8 +405,8 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
outPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, "cannot install uploaded image")
|
||||
if err := storage.InstallImage(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,20 +422,15 @@ func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
rsp.ErrRsp(c, -1, "file is required")
|
||||
}
|
||||
|
||||
func validateISOFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return errors.New("no filename")
|
||||
// validateImageFilename accepts what the emulated disk can expose: ISO images
|
||||
// and raw disk images. The name never reaches the filesystem unchecked, and
|
||||
// the rules are the ones a FAT volume can hold, so a medium named the same way
|
||||
// is reachable from the file manager too.
|
||||
func validateImageFilename(filename string) error {
|
||||
if !utils.IsMediaFile(filename) {
|
||||
return errors.New("only .iso and .img files allowed")
|
||||
}
|
||||
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
return errors.New("only .iso files allowed")
|
||||
}
|
||||
if !validISOFilename.MatchString(filename) {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
return nil
|
||||
return utils.ValidateFileName(filename)
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
@@ -431,19 +461,30 @@ func (s *Service) DownloadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// The download runs after this handler returns, and its temporary file is
|
||||
// on the same partition the raw medium exposes. Keep the partition reserved
|
||||
// until the goroutine has installed or removed that file.
|
||||
releaseDataPartitionWrite, err := storage.BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done, err := s.beginDownload(req.File, cancel)
|
||||
if err != nil {
|
||||
releaseDataPartitionWrite()
|
||||
cancel()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer releaseDataPartitionWrite()
|
||||
defer cancel()
|
||||
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(loaded, total int64) {
|
||||
s.setDownloadProgress(done, loaded, total)
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug("Image download canceled")
|
||||
@@ -488,7 +529,7 @@ func (s *Service) downloadRemoteImage(
|
||||
rawURL string,
|
||||
expectedSHA256 []byte,
|
||||
filename string,
|
||||
onProgress func(string),
|
||||
onProgress func(loaded, total int64),
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -505,7 +546,22 @@ func (s *Service) downloadRemoteImage(
|
||||
return fmt.Errorf("download request returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(imageDirectory, ".nanokvm-download-*")
|
||||
// The response headers say how large the image is: refuse it now instead
|
||||
// of filling the partition and failing at the end. An unknown length
|
||||
// (chunked responses) skips the check and is caught by the write itself.
|
||||
if err := ensureImageSpace(imageDirectory, resp.ContentLength); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A response without a length is capped by the free space instead, so a
|
||||
// server that never stops sending cannot fill the partition. One byte more
|
||||
// than the cap is read to tell "exactly fits" from "does not fit".
|
||||
limit, err := imageSpaceLimit(imageDirectory, resp.ContentLength)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(imageDirectory, transferTempPrefix+"download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary image failed: %w", err)
|
||||
}
|
||||
@@ -513,9 +569,9 @@ func (s *Service) downloadRemoteImage(
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
_, copyErr := io.Copy(lw, resp.Body)
|
||||
lw.stopTicker()
|
||||
lw := utils.NewProgressWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
written, copyErr := io.Copy(lw, io.LimitReader(resp.Body, limit+1))
|
||||
lw.Stop()
|
||||
closeErr := tempFile.Close()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -526,6 +582,15 @@ func (s *Service) downloadRemoteImage(
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close downloaded image failed: %w", closeErr)
|
||||
}
|
||||
if written > limit {
|
||||
return fmt.Errorf("not enough space in %s for the downloaded image", imageDirectory)
|
||||
}
|
||||
// net/http already fails a body shorter than its Content-Length and cuts
|
||||
// a longer one off, but an image that is not the size the server
|
||||
// announced is not one to install either way.
|
||||
if resp.ContentLength >= 0 && written != resp.ContentLength {
|
||||
return fmt.Errorf("downloaded image is %d bytes, the server announced %d", written, resp.ContentLength)
|
||||
}
|
||||
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
return errSHA256Mismatch
|
||||
@@ -535,69 +600,9 @@ func (s *Service) downloadRemoteImage(
|
||||
}
|
||||
|
||||
destPath := filepath.Join(imageDirectory, filename)
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded image failed: %w", err)
|
||||
if err := storage.InstallImage(tempPath, destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
writer io.Writer
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
onProgress func(string)
|
||||
}
|
||||
|
||||
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
|
||||
lw := &loggingWriter{
|
||||
writer: writer,
|
||||
totalSize: totalSize,
|
||||
onProgress: onProgress,
|
||||
}
|
||||
lw.startTicker()
|
||||
return lw
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) startTicker() {
|
||||
lw.ticker = time.NewTicker(2500 * time.Millisecond)
|
||||
lw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-lw.done:
|
||||
return
|
||||
case <-lw.ticker.C:
|
||||
lw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) stopTicker() {
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
lw.stopOnce.Do(func() {
|
||||
lw.ticker.Stop()
|
||||
close(lw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) updateProgress() {
|
||||
if lw.totalSize <= 0 || lw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
|
||||
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
|
||||
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) Write(p []byte) (int, error) {
|
||||
n, err := lw.writer.Write(p)
|
||||
lw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func headWithDescriptor(magic string) []byte {
|
||||
head := make([]byte, imageHeaderSize)
|
||||
copy(head[volumeDescriptorOffset:], magic)
|
||||
return head
|
||||
}
|
||||
|
||||
func TestIsSupportedImage(t *testing.T) {
|
||||
mbr := make([]byte, imageHeaderSize)
|
||||
mbr[0x1FE], mbr[0x1FF] = 0x55, 0xAA
|
||||
|
||||
gpt := make([]byte, imageHeaderSize)
|
||||
copy(gpt[0x200:], "EFI PART")
|
||||
|
||||
apfs := make([]byte, imageHeaderSize)
|
||||
copy(apfs[0x20:], "NXSB")
|
||||
|
||||
hfs := make([]byte, imageHeaderSize)
|
||||
copy(hfs[0x400:], "H+")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
head []byte
|
||||
want bool
|
||||
}{
|
||||
{"iso9660 image", headWithDescriptor("CD001"), true},
|
||||
{"udf bridge image", headWithDescriptor("CD001"), true},
|
||||
{"udf only image", headWithDescriptor("BEA01"), true},
|
||||
{"gpt disk image", gpt, true},
|
||||
{"apfs disk image", apfs, true},
|
||||
{"hfs+ disk image", hfs, true},
|
||||
{"mbr disk image", mbr, true},
|
||||
{"compressed dmg", make([]byte, imageHeaderSize), false},
|
||||
{"plain data", bytes.Repeat([]byte{0xA5}, imageHeaderSize), false},
|
||||
{"truncated image", headWithDescriptor("CD001")[:16], false},
|
||||
{"empty image", nil, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isSupportedImage(test.head); got != test.want {
|
||||
t.Fatalf("isSupportedImage(%s) = %v, want %v", test.name, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureImageSpace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ensureImageSpace(dir, 0); err != nil {
|
||||
t.Fatalf("an upload without a known size must not be rejected: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<20); err != nil {
|
||||
t.Fatalf("a small upload must fit: %v", err)
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(dir, 1<<62); err == nil {
|
||||
t.Fatal("an upload larger than the filesystem must be rejected")
|
||||
}
|
||||
|
||||
if err := ensureImageSpace(filepath.Join(dir, "missing"), 1<<20); err == nil {
|
||||
t.Fatal("a missing directory must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,18 @@ func (h *Hid) OpenNoLockWithRetry(timeout, delay time.Duration) error {
|
||||
return openNoLockWithRetry(h.OpenNoLock, timeout, delay)
|
||||
}
|
||||
|
||||
// ReopenAfterRebind reopens the HID devices after the USB gadget was
|
||||
// re-enumerated. The hidg nodes take a moment to come back, so the reopen is
|
||||
// retried. Devices built without HID (/boot/disable_hid) have nothing to open,
|
||||
// and a failure here must not turn a successful gadget operation into an
|
||||
// error, so it is only logged.
|
||||
func ReopenAfterRebind() {
|
||||
h := GetHid()
|
||||
if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil {
|
||||
log.Errorf("reopen HID devices after USB rebind failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
return open()
|
||||
|
||||
@@ -135,6 +135,14 @@ func ensureAPIKey(cfg Config) (Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RotateAPIKey replaces the device-wide API key. The key is shared by every
|
||||
// account that may read it, so once one of them loses the mcp grant the key it
|
||||
// may have copied has to stop working.
|
||||
func RotateAPIKey() error {
|
||||
_, err := updateConfig(regenerateAPIKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func regenerateAPIKey(cfg Config) (Config, error) {
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
|
||||
234
server/service/storage/cache.go
Normal file
234
server/service/storage/cache.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
)
|
||||
|
||||
// The shared files medium is the data partition block device handed to the
|
||||
// target machine while this device keeps it mounted: two writers on one
|
||||
// filesystem. The target machine's writes arrive through the emulated USB disk
|
||||
// and land in this device's page cache for that block device - the very cache
|
||||
// the mounted exFAT reads its directory data from, so the file data on this
|
||||
// side already is what the target machine wrote. What does not follow is the
|
||||
// metadata this side cached: a file the target grew keeps the size it had when
|
||||
// it was first read, and a directory that grew keeps the cluster chain it was
|
||||
// first walked with. Dropping the dentry and inode caches makes a listing see
|
||||
// the target machine's version. The page cache is deliberately left alone: it
|
||||
// holds that version already, and dropping it would only add card reads.
|
||||
//
|
||||
// Nothing tells this side that those block level writes happened. They bypass
|
||||
// the VFS, so inotify and fanotify stay silent, and the partition's write
|
||||
// counter in /proc/diskstats only moves once this side writes its own dirty
|
||||
// pages back - which can be tens of seconds after the target wrote, or when
|
||||
// the target itself asks the disk to flush. The watcher therefore refreshes on
|
||||
// a timer while the target machine owns the partition; the interval only
|
||||
// bounds how long the web UI can lag behind the target's own writeback.
|
||||
const (
|
||||
// partitionWatchInterval is how often the shared files view is refreshed
|
||||
// while the target machine owns the partition. Reading one directory is
|
||||
// cheap, so the interval is chosen to keep a listing from looking stuck,
|
||||
// not to save work.
|
||||
partitionWatchInterval = time.Second
|
||||
// partitionWriteQuiet is how long a change keeps counting as a transfer in
|
||||
// progress. The target machine flushes in bursts, and its own writeback can
|
||||
// hold the final size update for tens of seconds (Linux flushes dirty
|
||||
// metadata only after dirty_expire_centisecs), so a short quiet spell is
|
||||
// not the end of a copy.
|
||||
partitionWriteQuiet = 45 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
partitionCacheMu sync.Mutex
|
||||
partitionLastChange time.Time
|
||||
partitionBrowsedPath string
|
||||
partitionListeners []func()
|
||||
partitionWatchOnce sync.Once
|
||||
)
|
||||
|
||||
// startPartitionWatcher starts the periodic refresh of the shared files view.
|
||||
func startPartitionWatcher() {
|
||||
partitionWatchOnce.Do(func() {
|
||||
go watchPartitionChanges()
|
||||
})
|
||||
}
|
||||
|
||||
func watchPartitionChanges() {
|
||||
var lastSignature string
|
||||
|
||||
for {
|
||||
time.Sleep(partitionWatchInterval)
|
||||
|
||||
// Only the target machine's writes bypass this side's caches; while
|
||||
// this device is the only writer, the filesystem's own caches are
|
||||
// current and dropping them would be wasted work.
|
||||
if !IsAttached(partitionDevice) {
|
||||
continue
|
||||
}
|
||||
|
||||
invalidatePartitionCache()
|
||||
|
||||
// The signature is taken after the drop, so it compares what a listing
|
||||
// would show now. It is what tells a transfer in progress - the sizes
|
||||
// grow - from an idle medium, and it is also what keeps an idle shared
|
||||
// folder from making the web UI re-read it every second.
|
||||
signature := partitionSignature()
|
||||
if signature == lastSignature {
|
||||
continue
|
||||
}
|
||||
lastSignature = signature
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
partitionLastChange = time.Now()
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
notifyPartitionListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// SetPartitionBrowsePath tells the watcher which directory of the shared
|
||||
// folder the web UI is showing. A change inside a subdirectory does not move
|
||||
// the folder's own signature, so the directory being browsed is watched too.
|
||||
// An empty path stops watching a directory.
|
||||
func SetPartitionBrowsePath(browsePath string) {
|
||||
partitionCacheMu.Lock()
|
||||
partitionBrowsedPath = browsePath
|
||||
partitionCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// SubscribePartitionChanges registers fn for "the shared files view was
|
||||
// refreshed" notifications: once per refresh while the target machine owns the
|
||||
// partition. fn runs on the watcher goroutine.
|
||||
func SubscribePartitionChanges(fn func()) {
|
||||
partitionCacheMu.Lock()
|
||||
defer partitionCacheMu.Unlock()
|
||||
|
||||
partitionListeners = append(partitionListeners, fn)
|
||||
}
|
||||
|
||||
func notifyPartitionListeners() {
|
||||
partitionCacheMu.Lock()
|
||||
listeners := append([]func(){}, partitionListeners...)
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
for _, fn := range listeners {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
// partitionWriting reports whether the shared files changed within the quiet
|
||||
// window. A zero length file listed in that state is a transfer in progress,
|
||||
// not an empty file.
|
||||
func partitionWriting() bool {
|
||||
// Only the target machine's writes arrive without this side knowing, so a
|
||||
// hint left over from the last time it owned the partition would mislead a
|
||||
// file browser that is reading the medium itself now.
|
||||
if !IsAttached(partitionDevice) {
|
||||
return false
|
||||
}
|
||||
|
||||
partitionCacheMu.Lock()
|
||||
defer partitionCacheMu.Unlock()
|
||||
|
||||
return !partitionLastChange.IsZero() && time.Since(partitionLastChange) < partitionWriteQuiet
|
||||
}
|
||||
|
||||
// invalidatePartitionCache drops the stale metadata caches. It is the entry
|
||||
// point both for the watcher and for a listing, so a request never has to wait
|
||||
// for the next tick.
|
||||
func invalidatePartitionCache() {
|
||||
if !IsAttached(partitionDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
dropPartitionCaches()
|
||||
}
|
||||
|
||||
// partitionSignature summarizes what a listing would show: the shared folder
|
||||
// itself, which is where files are exchanged, and the directory the file
|
||||
// browser has open. Names, sizes and timestamps are all compared, because a
|
||||
// copy changes the size of an entry it already added.
|
||||
func partitionSignature() string {
|
||||
partitionCacheMu.Lock()
|
||||
browsed := partitionBrowsedPath
|
||||
partitionCacheMu.Unlock()
|
||||
|
||||
signature := directorySignature(mediaDirectory)
|
||||
if browsed == "" || browsed == "/" {
|
||||
return signature
|
||||
}
|
||||
|
||||
dir, err := diskfs.ResolveInside(mediaDirectory, browsed)
|
||||
if err != nil {
|
||||
return signature
|
||||
}
|
||||
|
||||
return signature + directorySignature(dir)
|
||||
}
|
||||
|
||||
func directorySignature(dir string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var signature strings.Builder
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue // vanished between the directory read and the stat
|
||||
}
|
||||
fmt.Fprintf(&signature, "%s|%d|%d|%t\n", entry.Name(), info.Size(), info.ModTime().Unix(), entry.IsDir())
|
||||
}
|
||||
|
||||
return signature.String()
|
||||
}
|
||||
|
||||
// drop_caches values. The sysctl only accepts 1 to 4 (the 5.10 table caps it
|
||||
// with extra2 = 4), so a combined value such as 6 is refused with EINVAL and
|
||||
// drops nothing. Bit 4 is written once on its own instead: it sets a sticky
|
||||
// flag in fs/drop_caches.c that keeps every later drop out of the kernel log,
|
||||
// which is how a share lasting a day used to fill the log on the data
|
||||
// partition.
|
||||
const (
|
||||
dropCachesQuiet = "4" // stop logging drops from now on
|
||||
dropCachesDentry = "2" // dentries and inodes
|
||||
dropCachesAllData = "3" // page cache, dentries and inodes
|
||||
)
|
||||
|
||||
var dropCachesQuietOnce sync.Once
|
||||
|
||||
func dropCaches(value string) {
|
||||
dropCachesQuietOnce.Do(func() {
|
||||
writeDropCaches(dropCachesQuiet)
|
||||
})
|
||||
writeDropCaches(value)
|
||||
}
|
||||
|
||||
func writeDropCaches(value string) {
|
||||
if err := os.WriteFile("/proc/sys/vm/drop_caches", []byte(value), 0); err != nil {
|
||||
log.Warnf("drop stale caches (%s) failed: %s", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropPartitionCaches drops the dentries and inodes, which is what makes a
|
||||
// listing see the target machine's entries and sizes. Dirty pages survive a
|
||||
// drop, so gadget writes still in flight are not lost.
|
||||
func dropPartitionCaches() {
|
||||
dropCaches(dropCachesDentry)
|
||||
}
|
||||
|
||||
// dropAllPartitionCaches throws away everything this side cached of the data
|
||||
// partition, page cache included: it runs when the partition stops being
|
||||
// served to the target machine, where a stale page written back would land on
|
||||
// top of what the target machine just wrote.
|
||||
func dropAllPartitionCaches() {
|
||||
dropCaches(dropCachesAllData)
|
||||
}
|
||||
64
server/service/storage/data.go
Normal file
64
server/service/storage/data.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Only one side may write the data partition at a time. The emulated USB disk
|
||||
// shares that partition with the target machine as a raw, writable disk, and
|
||||
// while it does the target machine is its only writer, so /data is remounted
|
||||
// read-only here: remounting keeps every writer on this side away from the
|
||||
// filesystem, not just the file manager that checks openMedium first.
|
||||
// Mounting an image read-only hands the partition back to this side, and so
|
||||
// does releasing the medium. Every read-only to writable transition drops the
|
||||
// cache built while the target owned the partition before writers are allowed
|
||||
// back in; callers must not remount it writable first and clean up afterwards.
|
||||
func SetDataWritable(writable bool) error {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &stat); err != nil {
|
||||
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
|
||||
}
|
||||
|
||||
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
|
||||
return nil
|
||||
}
|
||||
|
||||
option := "rw"
|
||||
if !writable {
|
||||
option = "ro"
|
||||
} else {
|
||||
// The target may have changed both file data and allocation metadata
|
||||
// while it owned the block device. Invalidate that view before the
|
||||
// filesystem becomes writable, on every path that hands it back.
|
||||
// A remount does not recount exFAT's used clusters (counted only at
|
||||
// mount), so free space stays stale and freeing what the target wrote
|
||||
// can underflow that count until /data is mounted afresh.
|
||||
dropAllPartitionCaches()
|
||||
}
|
||||
|
||||
if err := exec.Command("mount", "-o", "remount,"+option, mediaDirectory).Run(); err != nil {
|
||||
return fmt.Errorf("remount %s %s: %w", mediaDirectory, option, err)
|
||||
}
|
||||
|
||||
// Never leave a writable /data behind for a target machine that is writing
|
||||
// the same filesystem.
|
||||
var check unix.Statfs_t
|
||||
if err := unix.Statfs(mediaDirectory, &check); err != nil {
|
||||
return fmt.Errorf("stat %s: %w", mediaDirectory, err)
|
||||
}
|
||||
if (check.Flags&unix.ST_RDONLY == 0) != writable {
|
||||
return errors.New(mediaDirectory + " mount did not change")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataDiskAttached reports whether the emulated disk serves the whole data
|
||||
// partition to the target machine, which is the state /data is read-only in.
|
||||
func DataDiskAttached() bool {
|
||||
return IsAttached(partitionDevice)
|
||||
}
|
||||
245
server/service/storage/diskfs/diskfs.go
Normal file
245
server/service/storage/diskfs/diskfs.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Package diskfs gives the file manager access to the filesystem inside a
|
||||
// medium. A medium is a disk image stored on the data partition, so the only
|
||||
// way in and out is a loop mount: the device kernel has exFAT and FAT built
|
||||
// in, and ships mkfs.exfat plus busybox mkdosfs, so both creating and mounting
|
||||
// image files stays in the kernel instead of a hand written FAT reader.
|
||||
//
|
||||
// The medium must not be attached to the target machine while it is mounted
|
||||
// here. Callers enforce that (see service/storage); this package only tracks
|
||||
// its own mounts so concurrent requests share one.
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// Kind is the filesystem a medium holds.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindExFAT Kind = "exfat"
|
||||
KindFAT32 Kind = "fat32"
|
||||
KindFAT16 Kind = "fat16"
|
||||
KindFAT12 Kind = "fat12"
|
||||
KindISO9660 Kind = "iso9660"
|
||||
KindUDF Kind = "udf"
|
||||
KindUnknown Kind = "unknown"
|
||||
)
|
||||
|
||||
// Browsable reports whether the file manager can list files inside the medium.
|
||||
// ISO 9660 is readable but read-only and not part of v1.
|
||||
func (k Kind) Browsable() bool {
|
||||
switch k {
|
||||
case KindExFAT, KindFAT32, KindFAT16, KindFAT12:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Optical reports whether the medium holds a disc filesystem, which is what a
|
||||
// CD-ROM has to carry: the gadget reports 2048 byte sectors for it.
|
||||
func (k Kind) Optical() bool {
|
||||
return k == KindISO9660 || k == KindUDF
|
||||
}
|
||||
|
||||
// FileSystem is the type name the kernel mount needs. vfat covers every FAT
|
||||
// variant (msdos is the subset without long names).
|
||||
func (k Kind) FileSystem() string {
|
||||
switch k {
|
||||
case KindExFAT:
|
||||
return "exfat"
|
||||
case KindFAT32, KindFAT16, KindFAT12:
|
||||
return "vfat"
|
||||
case KindISO9660:
|
||||
return "iso9660"
|
||||
case KindUDF:
|
||||
return "udf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Entry is one directory entry inside a medium.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
ModTime time.Time `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// Caps is what the device can actually do. Mounting a medium needs a loop
|
||||
// device; the file manager reports a missing capability instead of failing
|
||||
// with a bare exec error, because a custom build may not have one.
|
||||
type Caps struct {
|
||||
Loop bool `json:"loop"`
|
||||
}
|
||||
|
||||
// ErrUnsupported reports a missing device capability.
|
||||
var ErrUnsupported = errors.New("unsupported on this device")
|
||||
|
||||
// ErrBusy reports a medium that cannot be handed out the way the caller
|
||||
// asked: a write meets a direct reader, or a direct read meets a writer.
|
||||
var ErrBusy = errors.New("the medium is busy")
|
||||
|
||||
// Probe reports the device's media capabilities. The answer cannot change
|
||||
// while the server runs, and the listing asks for it on every poll, so it is
|
||||
// computed once.
|
||||
func Probe() Caps {
|
||||
probeOnce.Do(func() {
|
||||
probedCaps = Caps{
|
||||
Loop: lookPath("losetup") && pathExists("/dev/loop-control"),
|
||||
}
|
||||
})
|
||||
|
||||
return probedCaps
|
||||
}
|
||||
|
||||
var (
|
||||
probeOnce sync.Once
|
||||
probedCaps Caps
|
||||
)
|
||||
|
||||
// Err describes what is missing, or nil when the device can browse media.
|
||||
func (c Caps) Err() error {
|
||||
if !c.Loop {
|
||||
return fmt.Errorf("%w: no loop device support", ErrUnsupported)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect reads the boot sector and reports the filesystem, so the UI knows
|
||||
// whether a medium can be browsed before anything is mounted.
|
||||
func Detect(imagePath string) (Kind, error) {
|
||||
file, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
return KindUnknown, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
boot := make([]byte, 512)
|
||||
if _, err := io.ReadFull(file, boot); err != nil {
|
||||
return KindUnknown, nil // too small to hold a filesystem
|
||||
}
|
||||
|
||||
if string(boot[3:11]) == "EXFAT " {
|
||||
return KindExFAT, nil
|
||||
}
|
||||
|
||||
if boot[510] == 0x55 && boot[511] == 0xaa {
|
||||
// A FAT32 volume also carries the legacy "FAT16" string at 0x36, so
|
||||
// the FAT32 field has to be looked at first.
|
||||
if fat := boot[0x52 : 0x52+8]; isFATLabel(fat) {
|
||||
return fatKind(string(fat)), nil
|
||||
}
|
||||
if fat := boot[0x36 : 0x36+8]; isFATLabel(fat) {
|
||||
return fatKind(string(fat)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return detectOptical(file), nil
|
||||
}
|
||||
|
||||
// opticalSectorSize is the sector size of a disc, and udfVRSDescriptors the
|
||||
// number of volume structure descriptors looked at. A UDF disc keeps its
|
||||
// volume recognition sequence right after any ISO 9660 descriptors, and a
|
||||
// handful of sectors covers both.
|
||||
const (
|
||||
opticalSectorSize = 2048
|
||||
udfVRSDescriptors = 32
|
||||
)
|
||||
|
||||
// detectOptical walks the volume recognition area 32 KiB into the image. An
|
||||
// ISO 9660 primary descriptor ("CD001") in the first slot is the common case;
|
||||
// a pure UDF disc, which many recent installers ship as, has no ISO 9660
|
||||
// descriptor at all and is only recognized by its NSR02/NSR03 descriptor.
|
||||
func detectOptical(file io.ReaderAt) Kind {
|
||||
id := make([]byte, 5)
|
||||
for i := 0; i < udfVRSDescriptors; i++ {
|
||||
offset := int64(0x8000 + i*opticalSectorSize + 1)
|
||||
if _, err := file.ReadAt(id, offset); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
switch string(id) {
|
||||
case "CD001":
|
||||
if i == 0 {
|
||||
return KindISO9660
|
||||
}
|
||||
case "NSR02", "NSR03":
|
||||
return KindUDF
|
||||
case "BEA01", "TEA01", "BOOT2", "CDW02":
|
||||
default:
|
||||
// The sequence ends at the first slot that is no descriptor.
|
||||
return KindUnknown
|
||||
}
|
||||
}
|
||||
|
||||
return KindUnknown
|
||||
}
|
||||
|
||||
func isFATLabel(label []byte) bool {
|
||||
return len(label) == 8 && (string(label[:5]) == "FAT12" || string(label[:5]) == "FAT16" || string(label[:5]) == "FAT32")
|
||||
}
|
||||
|
||||
func fatKind(label string) Kind {
|
||||
switch label[:5] {
|
||||
case "FAT12":
|
||||
return KindFAT12
|
||||
case "FAT16":
|
||||
return KindFAT16
|
||||
default:
|
||||
return KindFAT32
|
||||
}
|
||||
}
|
||||
|
||||
func lookPath(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func trimOutput(out []byte) string {
|
||||
const limit = 200
|
||||
text := string(out)
|
||||
if len(text) > limit {
|
||||
text = text[:limit]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ResolveInside resolves rel inside root and refuses anything that escapes it.
|
||||
// Callers validate paths before this, so a failure here means a bug.
|
||||
func ResolveInside(root, rel string) (string, error) {
|
||||
if rel == "" || rel == "/" {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
inside, err := filepath.Rel(root, full)
|
||||
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: %s escapes the medium", utils.ErrInvalidMediaPath, rel)
|
||||
}
|
||||
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
130
server/service/storage/diskfs/diskfs_test.go
Normal file
130
server/service/storage/diskfs/diskfs_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeImage(t *testing.T, descriptors ...string) string {
|
||||
t.Helper()
|
||||
image := make([]byte, 0x8000+(len(descriptors)+2)*opticalSectorSize)
|
||||
for i, id := range descriptors {
|
||||
copy(image[0x8000+i*opticalSectorSize+1:], id)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "disc.iso")
|
||||
if err := os.WriteFile(path, image, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDetectOptical(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
descriptors []string
|
||||
want Kind
|
||||
}{
|
||||
{"iso9660", []string{"CD001", "CD001"}, KindISO9660},
|
||||
{"iso9660 with udf bridge", []string{"CD001", "BEA01", "NSR02", "TEA01"}, KindISO9660},
|
||||
{"pure udf 1.02", []string{"BEA01", "NSR02", "TEA01"}, KindUDF},
|
||||
{"pure udf 2.x", []string{"BEA01", "NSR03", "TEA01"}, KindUDF},
|
||||
{"extended area without nsr", []string{"BEA01", "TEA01"}, KindUnknown},
|
||||
{"nothing", nil, KindUnknown},
|
||||
{"nsr after a gap", []string{"BEA01", "", "NSR03"}, KindUnknown},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
kind, err := Detect(writeImage(t, tt.descriptors...))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", tt.name, err)
|
||||
continue
|
||||
}
|
||||
if kind != tt.want {
|
||||
t.Errorf("%s: Detect = %q, want %q", tt.name, kind, tt.want)
|
||||
}
|
||||
if kind.Optical() != (tt.want == KindISO9660 || tt.want == KindUDF) {
|
||||
t.Errorf("%s: Optical = %v", tt.name, kind.Optical())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTransferTemp(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
".kvm-upload-0a1b2c3d": true,
|
||||
".kvm-upload-00000000": true,
|
||||
".kvm-upload-0A1B2C3D": false,
|
||||
".kvm-upload-0a1b2c3": false,
|
||||
".kvm-upload-notes.txt": false,
|
||||
".nanokvm-upload-123456789": true,
|
||||
".nanokvm-download-4294967295": true,
|
||||
".nanokvm-download-": false,
|
||||
".nanokvm-config": false,
|
||||
".nanokvm-upload-12ab": false,
|
||||
"ubuntu.iso": false,
|
||||
}
|
||||
for name, want := range tests {
|
||||
if got := IsTransferTemp(name); got != want {
|
||||
t.Errorf("IsTransferTemp(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirHidesTransferTemps(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"a.txt", ".kvm-upload-0a1b2c3d", ".nanokvm-download-42", ".nanokvm-mine"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := listDir(root, "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name)
|
||||
}
|
||||
if len(names) != 2 || names[0] != ".nanokvm-mine" || names[1] != "a.txt" {
|
||||
t.Errorf("listDir = %v, want [.nanokvm-mine a.txt]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileRefusesSymlinks(t *testing.T) {
|
||||
outside := filepath.Join(t.TempDir(), "secret")
|
||||
if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "file"), []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if file, _, err := openFile(root, "link"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Error("openFile followed a symlink")
|
||||
}
|
||||
if file, _, err := openFile(root, "dir"); err == nil {
|
||||
_ = file.Close()
|
||||
t.Error("openFile opened a directory")
|
||||
}
|
||||
|
||||
file, entry, err := openFile(root, "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
data, _ := io.ReadAll(file)
|
||||
if string(data) != "ok" || entry.Size != 2 {
|
||||
t.Errorf("openFile read %q, size %d", data, entry.Size)
|
||||
}
|
||||
}
|
||||
617
server/service/storage/diskfs/mount.go
Normal file
617
server/service/storage/diskfs/mount.go
Normal file
@@ -0,0 +1,617 @@
|
||||
package diskfs
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// MountRoot holds the loop mounts while a medium is browsed. It lives under
|
||||
// /run next to the transfer lock: a reboot clears it, and it is not inside
|
||||
// /data where the medium listing walks every file.
|
||||
const MountRoot = "/run/nanokvm/media"
|
||||
|
||||
// loopDeviceLimit is the number of loop devices the kernel is configured with;
|
||||
// scanning them all is cheaper than parsing losetup output.
|
||||
const loopDeviceLimit = 8
|
||||
|
||||
// UploadPrefix marks a partially written upload inside a medium. It is deleted
|
||||
// on failure, renamed into place on success, and never handed out as a file.
|
||||
const UploadPrefix = ".kvm-upload-"
|
||||
|
||||
// DownloadTempPrefix is what the image downloader (service/download) names
|
||||
// the file it is writing while the transfer runs, through os.CreateTemp with
|
||||
// "upload-*" or "download-*" after it.
|
||||
const DownloadTempPrefix = ".nanokvm-"
|
||||
|
||||
// IsTransferTemp reports whether name is exactly what one of our transfers
|
||||
// names its partial file: UploadPrefix plus randomSuffix's eight hex digits,
|
||||
// or DownloadTempPrefix plus the decimal number os.CreateTemp puts in place of
|
||||
// the "*". A user's own file that merely starts the same way is not one.
|
||||
func IsTransferTemp(name string) bool {
|
||||
if rest, ok := strings.CutPrefix(name, UploadPrefix); ok {
|
||||
return len(rest) == 8 && strings.Trim(rest, "0123456789abcdef") == ""
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(name, DownloadTempPrefix); ok {
|
||||
for _, kind := range []string{"upload-", "download-"} {
|
||||
if digits, ok := strings.CutPrefix(rest, kind); ok {
|
||||
return digits != "" && strings.Trim(digits, "0123456789") == ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Image is a mounted medium. Handles are reference counted, because a download
|
||||
// may stream for minutes while the file list is refreshed, and both have to
|
||||
// see the same mount.
|
||||
type Image interface {
|
||||
List(rel string) ([]Entry, error)
|
||||
Open(rel string) (io.ReadSeekCloser, Entry, error)
|
||||
Write(rel string, r io.Reader) (int64, error)
|
||||
Remove(rel string) error
|
||||
StatFS() (total, available uint64, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type mount struct {
|
||||
path string
|
||||
mountpoint string
|
||||
loopDevice string
|
||||
writable bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
mountsMu sync.Mutex
|
||||
mounts = map[string]*mount{}
|
||||
// readers counts direct readers of a medium's file: an image download
|
||||
// streams the file itself rather than a mount. A writable mount would
|
||||
// tear the stream those readers see, so it is refused while any of them
|
||||
// is active. Guarded by mountsMu, so the check and the mount stay one step.
|
||||
readers = map[string]int{}
|
||||
)
|
||||
|
||||
// Mount opens the filesystem inside a medium and returns a handle to it. The
|
||||
// caller is responsible for making sure the medium is not attached to the
|
||||
// target machine at the same time: two writers on one filesystem is how an
|
||||
// image gets destroyed.
|
||||
func Mount(imagePath string, writable bool) (Image, error) {
|
||||
if err := Probe().Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kind, err := Detect(imagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !kind.Browsable() {
|
||||
return nil, fmt.Errorf("%w: %s holds no filesystem the file manager can browse", ErrUnsupported, imagePath)
|
||||
}
|
||||
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
if existing, ok := mounts[imagePath]; ok {
|
||||
// Sharing a read-write mount for reading is fine; the other way round
|
||||
// is not, because the mount was set up read only.
|
||||
if writable && !existing.writable {
|
||||
return nil, fmt.Errorf("medium is already open for reading")
|
||||
}
|
||||
existing.refs++
|
||||
return &handle{mount: existing}, nil
|
||||
}
|
||||
|
||||
// A direct reader holds no mount, so it does not show up above; a write
|
||||
// would tear the stream it is reading.
|
||||
if writable && readers[imagePath] > 0 {
|
||||
return nil, fmt.Errorf("%w: %s is being read", ErrBusy, imagePath)
|
||||
}
|
||||
|
||||
attached, err := attach(imagePath, kind, writable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attached.refs = 1
|
||||
mounts[imagePath] = attached
|
||||
|
||||
return &handle{mount: attached}, nil
|
||||
}
|
||||
|
||||
// AcquireReadPin marks a medium's file as being read directly, outside a
|
||||
// mount, and refuses while the file manager holds the medium open for
|
||||
// writing: the stream would otherwise serve a file that changes underneath
|
||||
// it. The returned function releases the pin and is safe to call twice.
|
||||
func AcquireReadPin(imagePath string) (func(), error) {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
if existing, ok := mounts[imagePath]; ok && existing.writable {
|
||||
return nil, fmt.Errorf("%w: %s is open for writing", ErrBusy, imagePath)
|
||||
}
|
||||
|
||||
readers[imagePath]++
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
readers[imagePath]--
|
||||
if readers[imagePath] <= 0 {
|
||||
delete(readers, imagePath)
|
||||
}
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MountedImages lists the media the file manager currently holds open.
|
||||
func MountedImages() []string {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
paths := make([]string, 0, len(mounts))
|
||||
for path := range mounts {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// IsMounted reports whether the file manager currently holds a medium open.
|
||||
func IsMounted(imagePath string) bool {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
_, ok := mounts[imagePath]
|
||||
return ok
|
||||
}
|
||||
|
||||
func attach(imagePath string, kind Kind, writable bool) (*mount, error) {
|
||||
if err := os.MkdirAll(MountRoot, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", MountRoot, err)
|
||||
}
|
||||
|
||||
mountpoint := filepath.Join(MountRoot, mountName(imagePath))
|
||||
if err := os.MkdirAll(mountpoint, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", mountpoint, err)
|
||||
}
|
||||
|
||||
loopDevice, err := attachLoop(imagePath, writable)
|
||||
if err != nil {
|
||||
_ = os.Remove(mountpoint)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Neither exFAT nor FAT can hold a symlink, but the image is untrusted
|
||||
// and the server runs as root: refusing to follow one keeps any path
|
||||
// inside the mount from resolving outside it, whatever the filesystem.
|
||||
flags := uintptr(unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOSYMFOLLOW)
|
||||
if !writable {
|
||||
flags |= unix.MS_RDONLY
|
||||
}
|
||||
|
||||
if err := unix.Mount(loopDevice, mountpoint, kind.FileSystem(), flags, ""); err != nil {
|
||||
detachLoop(loopDevice)
|
||||
_ = os.Remove(mountpoint)
|
||||
return nil, fmt.Errorf("mount %s as %s: %w", imagePath, kind, err)
|
||||
}
|
||||
|
||||
return &mount{
|
||||
path: imagePath,
|
||||
mountpoint: mountpoint,
|
||||
loopDevice: loopDevice,
|
||||
writable: writable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mountName builds a filesystem safe directory name that still says which
|
||||
// medium it belongs to.
|
||||
func mountName(imagePath string) string {
|
||||
sum := sha256.Sum256([]byte(imagePath))
|
||||
|
||||
base := filepath.Base(imagePath)
|
||||
base = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, base)
|
||||
|
||||
return base + "-" + hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
func attachLoop(imagePath string, writable bool) (string, error) {
|
||||
device, err := freeLoopDevice()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if !writable {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
args = append(args, device, imagePath)
|
||||
|
||||
if out, err := exec.Command("losetup", args...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("losetup %s: %v: %s", imagePath, err, trimOutput(out))
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func detachLoop(device string) {
|
||||
if device == "" {
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("losetup", "-d", device).CombinedOutput(); err != nil {
|
||||
logrus.Errorf("detach loop device %s failed: %s: %s", device, err, trimOutput(out))
|
||||
}
|
||||
}
|
||||
|
||||
func freeLoopDevice() (string, error) {
|
||||
for i := 0; i < loopDeviceLimit; i++ {
|
||||
name := fmt.Sprintf("loop%d", i)
|
||||
device := "/dev/" + name
|
||||
if !pathExists(device) {
|
||||
continue
|
||||
}
|
||||
|
||||
// This kernel only creates the loop attributes once the device has been
|
||||
// bound, so a missing backing_file is a free device rather than an
|
||||
// error.
|
||||
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
|
||||
if os.IsNotExist(err) {
|
||||
return device, nil
|
||||
}
|
||||
if err == nil && strings.TrimSpace(string(backing)) == "" {
|
||||
return device, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("no free loop device")
|
||||
}
|
||||
|
||||
type handle struct {
|
||||
mount *mount
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (h *handle) List(rel string) ([]Entry, error) {
|
||||
return listDir(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
return openFile(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) Write(rel string, r io.Reader) (int64, error) {
|
||||
if !h.mount.writable {
|
||||
return 0, errors.New("medium is mounted read-only")
|
||||
}
|
||||
return writeFile(h.mount.mountpoint, rel, r)
|
||||
}
|
||||
|
||||
func (h *handle) Remove(rel string) error {
|
||||
return removeFile(h.mount.mountpoint, rel)
|
||||
}
|
||||
|
||||
func (h *handle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.mount.mountpoint)
|
||||
}
|
||||
|
||||
// dirHandle is a medium that needs no loop device because it is already a
|
||||
// directory on a mounted filesystem: the data partition. The emulated disk
|
||||
// hands that same directory to the target machine, so the file manager reads
|
||||
// and writes exactly what the target machine sees.
|
||||
type dirHandle struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// OpenDirectory exposes root as a medium. Callers must make sure the target
|
||||
// machine is not using it at the same time; the file manager does that with
|
||||
// the disk state.
|
||||
func OpenDirectory(root string) (Image, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory", root)
|
||||
}
|
||||
|
||||
return &dirHandle{root: root}, nil
|
||||
}
|
||||
|
||||
func (h *dirHandle) List(rel string) ([]Entry, error) {
|
||||
return listDir(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Open(rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
return openFile(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Write(rel string, r io.Reader) (int64, error) {
|
||||
return writeFile(h.root, rel, r)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Remove(rel string) error {
|
||||
return removeFile(h.root, rel)
|
||||
}
|
||||
|
||||
func (h *dirHandle) StatFS() (total, available uint64, err error) {
|
||||
return utils.Statfs(h.root)
|
||||
}
|
||||
|
||||
func (h *dirHandle) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDir(root, rel string) ([]Entry, error) {
|
||||
dir, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dirents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(dirents))
|
||||
for _, dirent := range dirents {
|
||||
// A partial transfer is not a file yet, and deleting it from the web
|
||||
// UI would lose the data being written.
|
||||
if IsTransferTemp(dirent.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := dirent.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryOf(info))
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].IsDir != entries[j].IsDir {
|
||||
return entries[i].IsDir
|
||||
}
|
||||
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func openFile(root, rel string) (io.ReadSeekCloser, Entry, error) {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
|
||||
// The entry is opened before it is looked at, and a symlink is refused
|
||||
// rather than followed, so what is checked is what gets streamed.
|
||||
file, err := os.OpenFile(full, os.O_RDONLY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, Entry{}, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return nil, Entry{}, fmt.Errorf("%s is not a regular file", rel)
|
||||
}
|
||||
|
||||
return file, entryOf(info), nil
|
||||
}
|
||||
|
||||
// Write stores r as rel inside the medium and reports how many bytes landed.
|
||||
// The bytes go to a hidden name first and are renamed into place, so a failed
|
||||
// or cancelled upload never leaves a half written file where the target
|
||||
// machine can see it.
|
||||
func writeFile(root, rel string, r io.Reader) (int64, error) {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
return 0, fmt.Errorf("%s already exists", rel)
|
||||
}
|
||||
|
||||
tempPath := filepath.Join(filepath.Dir(full), UploadPrefix+randomSuffix())
|
||||
file, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
written, err := io.Copy(file, r)
|
||||
if err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
if closeErr := file.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, full); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func removeFile(root, rel string) error {
|
||||
full, err := ResolveInside(root, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if full == root {
|
||||
return errors.New("refusing to remove the medium root")
|
||||
}
|
||||
|
||||
// A directory goes with everything it holds: the web UI confirms the
|
||||
// delete first, and answering an explicit yes with "directory not empty"
|
||||
// helps no one. ResolveInside keeps the walk inside the medium.
|
||||
return os.RemoveAll(full)
|
||||
}
|
||||
|
||||
func (h *handle) Close() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.closed {
|
||||
return nil
|
||||
}
|
||||
h.closed = true
|
||||
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
h.mount.refs--
|
||||
if h.mount.refs > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(mounts, h.mount.path)
|
||||
return detach(h.mount)
|
||||
}
|
||||
|
||||
func detach(m *mount) error {
|
||||
var errs []error
|
||||
|
||||
if err := unix.Unmount(m.mountpoint, 0); err != nil {
|
||||
// A medium the file manager still holds open by accident must not keep
|
||||
// the loop device busy forever.
|
||||
if lazyErr := unix.Unmount(m.mountpoint, unix.MNT_DETACH); lazyErr != nil {
|
||||
errs = append(errs, fmt.Errorf("unmount %s: %w", m.mountpoint, lazyErr))
|
||||
}
|
||||
}
|
||||
|
||||
detachLoop(m.loopDevice)
|
||||
_ = os.Remove(m.mountpoint)
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// CleanupStale detaches every mount and loop device left behind under the
|
||||
// mount root. At startup nothing can be mounted by this process yet, so
|
||||
// whatever is there is a leftover from a crash, a restart or an update; at
|
||||
// shutdown it releases the mounts this process still holds.
|
||||
func CleanupStale() {
|
||||
mountsMu.Lock()
|
||||
defer mountsMu.Unlock()
|
||||
|
||||
for _, mountpoint := range staleMountpoints() {
|
||||
if err := unix.Unmount(mountpoint, unix.MNT_DETACH); err != nil {
|
||||
logrus.Errorf("cleanup stale mount %s failed: %s", mountpoint, err)
|
||||
}
|
||||
// The directory is ours to remove; a non-empty one is left alone.
|
||||
_ = os.Remove(mountpoint)
|
||||
}
|
||||
|
||||
for _, device := range loopsBackingMedia() {
|
||||
detachLoop(device)
|
||||
}
|
||||
|
||||
// Mount points are created per medium and only exist while it is mounted,
|
||||
// so whatever is still in the root is a leftover. Removing a directory
|
||||
// fails while something is mounted on it, which is the safety we want.
|
||||
if entries, err := os.ReadDir(MountRoot); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
_ = os.Remove(filepath.Join(MountRoot, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mounts = map[string]*mount{}
|
||||
}
|
||||
|
||||
func staleMountpoints() []string {
|
||||
data, err := os.ReadFile("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
// /proc escapes spaces in mount points; our names never contain any.
|
||||
mountpoint := fields[1]
|
||||
if mountpoint == MountRoot || strings.HasPrefix(mountpoint, MountRoot+"/") {
|
||||
paths = append(paths, mountpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// loopsBackingMedia finds loop devices whose backing file is a medium, so a
|
||||
// leftover device is released even when its mount is already gone.
|
||||
func loopsBackingMedia() []string {
|
||||
var devices []string
|
||||
|
||||
for i := 0; i < loopDeviceLimit; i++ {
|
||||
name := fmt.Sprintf("loop%d", i)
|
||||
backing, err := os.ReadFile("/sys/block/" + name + "/loop/backing_file")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(string(backing))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if path == utils.MediaDirectory || strings.HasPrefix(path, utils.MediaDirectory+"/") {
|
||||
devices = append(devices, "/dev/"+name)
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func entryOf(info os.FileInfo) Entry {
|
||||
return Entry{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
buf := make([]byte, 4)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "00000000"
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
453
server/service/storage/files.go
Normal file
453
server/service/storage/files.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// File handlers work inside a medium that is not attached to the target
|
||||
// machine. A medium the target machine owns is refused by openMedium: the two
|
||||
// of us writing one filesystem is how it gets destroyed.
|
||||
|
||||
// requireFileWrite picks the grant the medium in the request needs. One path
|
||||
// serves two media types: an image holds library content (library.write), while
|
||||
// the shared partition is what the emulated disk hands to the target machine, so
|
||||
// writing into it belongs to medium.attach. The route admits either grant and
|
||||
// this is the only place the pair is narrowed, because which one applies is not
|
||||
// visible to a path-based rule.
|
||||
func requireFileWrite(c *gin.Context, mediaType string) bool {
|
||||
permission := authn.PermLibraryWrite
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
permission = authn.PermMediumAttach
|
||||
}
|
||||
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
if principal.Has(permission) {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
return false
|
||||
}
|
||||
|
||||
// requireLibraryWrite guards a write through the shared partition that changes
|
||||
// the library. The library is every .iso and .img on the volume, wherever it
|
||||
// was put (findImages walks all of /data), so creating, replacing or removing
|
||||
// one through the partition changes it as much as the library endpoints do,
|
||||
// and medium.attach alone must not reach that far.
|
||||
func requireLibraryWrite(c *gin.Context) bool {
|
||||
principal, ok := middleware.CurrentPrincipal(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
if principal.Has(authn.PermLibraryWrite) {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, "forbidden")
|
||||
return false
|
||||
}
|
||||
|
||||
// holdsMedia reports whether full is a disk image or a directory with one
|
||||
// anywhere below it.
|
||||
func holdsMedia(full string) (bool, error) {
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return utils.IsMediaFile(full), nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return utils.IsMediaFile(info.Name()), nil
|
||||
}
|
||||
|
||||
found := false
|
||||
err = filepath.WalkDir(full, func(_ string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() && utils.IsMediaFile(entry.Name()) {
|
||||
found = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
|
||||
// uploadMaxBytes is a hard ceiling for a single upload, so a runaway request
|
||||
// cannot fill the medium before the free space check has had its say.
|
||||
const uploadMaxBytes = 8 << 30
|
||||
|
||||
func (s *Service) ListMediaFiles(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mediaType := c.Query("type")
|
||||
rel := c.Query("rel")
|
||||
dir, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The watcher pushes "the shared files changed" for the folder the UI
|
||||
// has open, which is not necessarily its root, so it is told which one
|
||||
// that is. Listing anything else stops that watch.
|
||||
SetPartitionBrowsePath(dir)
|
||||
invalidatePartitionCache()
|
||||
} else {
|
||||
SetPartitionBrowsePath("")
|
||||
}
|
||||
|
||||
image, err := openMedium(mediaType, rel, false)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
entries, err := image.List(dir)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("list %s: %w", dir, err))
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]proto.MediaFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
files = append(files, proto.MediaFile{
|
||||
Name: entry.Name,
|
||||
Size: entry.Size,
|
||||
IsDir: entry.IsDir,
|
||||
Modified: entry.ModTime,
|
||||
})
|
||||
}
|
||||
|
||||
total, free, _ := image.StatFS()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetMediaFilesRsp{
|
||||
Path: dir,
|
||||
Entries: files,
|
||||
Free: free,
|
||||
Total: total,
|
||||
Writing: mediaType == proto.MediaTypePartition && partitionWriting(),
|
||||
})
|
||||
|
||||
log.Debugf("list media files success, %s/%s total %d", rel, dir, len(files))
|
||||
}
|
||||
|
||||
func (s *Service) DownloadMediaFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rel := c.Query("rel")
|
||||
entryPath, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("type") == proto.MediaTypePartition {
|
||||
invalidatePartitionCache()
|
||||
}
|
||||
|
||||
image, err := openMedium(c.Query("type"), rel, false)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
file, entry, err := image.Open(entryPath)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("open %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
// The download streams straight out of the mounted image; ServeContent adds
|
||||
// range support and the headers a browser download needs.
|
||||
c.Header("Content-Disposition", attachmentHeader(path.Base(entryPath)))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(c.Writer, c.Request, entry.Name, entry.ModTime, file)
|
||||
}
|
||||
|
||||
func (s *Service) UploadMediaFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The medium travels in the query string so the multipart body holds
|
||||
// nothing but the file: reading a form field would make net/http buffer the
|
||||
// whole upload in memory before the medium could even be opened.
|
||||
mediaType := c.Query("type")
|
||||
rel := c.Query("rel")
|
||||
dir, err := utils.ValidateMediumPath(c.Query("path"))
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Content-Length covers the multipart framing as well, so it is an upper
|
||||
// bound on the file: good enough to refuse an upload that cannot fit.
|
||||
size := c.Request.ContentLength
|
||||
|
||||
if !requireFileWrite(c, mediaType) {
|
||||
return
|
||||
}
|
||||
|
||||
image, err := openMedium(mediaType, rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
// A chunked request states no length, so the free space is what has to cap
|
||||
// it instead: without that, an upload that never ends could fill the medium.
|
||||
limit := int64(uploadMaxBytes)
|
||||
limitedBySpace := false
|
||||
if _, free, err := image.StatFS(); err == nil {
|
||||
if size > 0 {
|
||||
if uint64(size) > free {
|
||||
respondError(c, fmt.Errorf("%w: the medium has %d MiB free", errNoSpace, free>>20))
|
||||
return
|
||||
}
|
||||
} else if free < uint64(limit) {
|
||||
limit = int64(free)
|
||||
limitedBySpace = true
|
||||
}
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid multipart request")
|
||||
return
|
||||
}
|
||||
|
||||
part, err := nextFilePart(reader)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = part.Close()
|
||||
}()
|
||||
|
||||
name, err := uploadName(part.FileName())
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
target := name
|
||||
if dir != "/" {
|
||||
target = path.Join(dir, name)
|
||||
}
|
||||
|
||||
// exFAT folds case with its own table, so the name in the request is not
|
||||
// necessarily the file it replaces: check the on-disk spelling as well.
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
touchesLibrary := utils.IsMediaFile(name)
|
||||
if !touchesLibrary {
|
||||
full, err := utils.CanonicalPathInside(mediaDirectory, target)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
touchesLibrary = utils.IsMediaFile(full)
|
||||
}
|
||||
if touchesLibrary && !requireLibraryWrite(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An upload is a long transfer like any other: it takes the single job
|
||||
// slot so the web UI can report progress and nothing else writes to the
|
||||
// data partition while it runs.
|
||||
done, err := s.beginJob(c.Request.Context(), target)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The upload is read from rather than written to, so the progress writer
|
||||
// only counts: the bytes go to the medium through the tee.
|
||||
progress := utils.NewProgressWriter(io.Discard, size, func(loaded, total int64) {
|
||||
s.setJobProgress(done, loaded, total)
|
||||
})
|
||||
defer progress.Stop()
|
||||
|
||||
written, err := image.Write(target, io.TeeReader(part, progress))
|
||||
if err != nil {
|
||||
s.finishJob(done, jobStateFailed, err.Error())
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if limitedBySpace && errors.As(err, &tooLarge) {
|
||||
respondError(c, fmt.Errorf("%w: the medium could not hold the upload", errNoSpace))
|
||||
return
|
||||
}
|
||||
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
s.finishJob(done, jobStateSuccess, "")
|
||||
|
||||
rsp.OkRspWithData(c, &proto.MediaFile{Name: name, Size: written})
|
||||
log.Debugf("upload %s to medium %s success", target, rel)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMediaFile(c *gin.Context) {
|
||||
var req proto.MediaPathReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if !requireFileWrite(c, req.Type) {
|
||||
return
|
||||
}
|
||||
|
||||
entryPath, err := utils.ValidateMediumPath(req.Path)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if entryPath == "/" {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a file path is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Our own transfer temporaries are not files to manage while a transfer
|
||||
// runs: deleting one its transfer is writing loses the data silently. One
|
||||
// found with no transfer running was left by an interrupted one, and the
|
||||
// user may clear it.
|
||||
if name := path.Base(entryPath); diskfs.IsTransferTemp(name) && TransferInProgress() {
|
||||
respondError(c, fmt.Errorf("%w: %s belongs to a running transfer", errMediumBusy, name))
|
||||
return
|
||||
}
|
||||
|
||||
// A directory is removed with everything below it, the temporary of an
|
||||
// upload into it included.
|
||||
if target, ok := s.activeJobTarget(); ok && pathWithin(target, entryPath) {
|
||||
respondError(c, fmt.Errorf("%w: an upload into %s is running", errMediumBusy, entryPath))
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting through the shared partition has no loop mount to keep the
|
||||
// gadget away. Hold the operation lock until the unlink is done so a
|
||||
// concurrent mount cannot attach the file after the attachment check.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
// A file the target machine is being served as its medium must not vanish
|
||||
// under it: the gadget keeps reading the inode it opened, so the target
|
||||
// machine would be left with a medium whose contents are already gone. An
|
||||
// image the file manager has mounted is just as much in use. A directory
|
||||
// is removed recursively, so the check covers everything below the path as
|
||||
// well. The path is resolved to its on-disk spelling first: exFAT folds
|
||||
// case, so "IMAGES" is the directory the attached "images/win.iso" is in.
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
full, err := utils.CanonicalPathInside(mediaDirectory, entryPath)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Not knowing what the gadget serves is no reason to assume nothing.
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
if err := deleteGuard(full, status.Backing, diskfs.MountedImages()); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
touchesLibrary, err := holdsMedia(full)
|
||||
if err != nil {
|
||||
respondError(c, fmt.Errorf("refusing to delete %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
if touchesLibrary && !requireLibraryWrite(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
image, err := openMediumLocked(req.Type, req.Rel, true)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer closeMedia(image)
|
||||
|
||||
if err := image.Remove(entryPath); err != nil {
|
||||
respondError(c, fmt.Errorf("remove %s: %w", entryPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete %s from medium %s success", entryPath, req.Rel)
|
||||
}
|
||||
|
||||
// nextFilePart walks the multipart body to the uploaded file, skipping any
|
||||
// field that came before it.
|
||||
func nextFilePart(reader *multipart.Reader) (*multipart.Part, error) {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("%w: no file in the request", utils.ErrInvalidMediaPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read multipart request: %w", err)
|
||||
}
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
return part, nil
|
||||
}
|
||||
}
|
||||
|
||||
// uploadName reduces the browser supplied file name to something the medium
|
||||
// can hold. The client picks it, so it is treated as untrusted.
|
||||
func uploadName(raw string) (string, error) {
|
||||
name := filepath.Base(raw)
|
||||
if err := utils.ValidateFileName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func closeMedia(image io.Closer) {
|
||||
if err := image.Close(); err != nil {
|
||||
log.Errorf("close medium failed: %s", err)
|
||||
}
|
||||
}
|
||||
391
server/service/storage/gadget.go
Normal file
391
server/service/storage/gadget.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// The emulated USB disk is one LUN of the configfs mass storage function.
|
||||
// Everything here is sysfs poking, and the kernel owns the rules:
|
||||
//
|
||||
// - writing an empty value to lun.0/file detaches the medium (the target
|
||||
// machine sees a media removal), writing a path attaches one;
|
||||
// - the read-only flag can only change while no medium is attached, so a
|
||||
// mount always goes eject -> flags -> inquiry -> file;
|
||||
// - a host that locked the medium (prevent_medium_removal, which Windows
|
||||
// sets while the disk is mounted) makes both fail with EBUSY.
|
||||
const (
|
||||
gadgetRoot = "/sys/kernel/config/usb_gadget/g0"
|
||||
gadgetUDC = gadgetRoot + "/UDC"
|
||||
gadgetUDCDir = "/sys/class/udc"
|
||||
massStorageFunc = gadgetRoot + "/functions/mass_storage.disk0"
|
||||
massStorageLink = gadgetRoot + "/configs/c.1/mass_storage.disk0"
|
||||
lunCDRomFlag = massStorageFunc + "/lun.0/cdrom"
|
||||
lunMountDevice = massStorageFunc + "/lun.0/file"
|
||||
lunInquiry = massStorageFunc + "/lun.0/inquiry_string"
|
||||
lunROFlag = massStorageFunc + "/lun.0/ro"
|
||||
// usbProductAttr is the device name the target machine shows. An image is
|
||||
// named after its file, the shared partition keeps the product name.
|
||||
usbProductAttr = gadgetRoot + "/strings/0x409/product"
|
||||
)
|
||||
|
||||
const (
|
||||
usbProductName = "NanoKVM"
|
||||
// The SCSI inquiry product field holds 16 bytes, the USB product string 126.
|
||||
inquiryProductSize = 16
|
||||
usbProductSize = 126
|
||||
)
|
||||
|
||||
// ErrMediumLocked reports that the target machine is still using the medium.
|
||||
// It is a normal condition, not a failure: the user has to eject the disk
|
||||
// there before the web UI can take it back.
|
||||
var ErrMediumLocked = errors.New("the target machine is using the medium")
|
||||
|
||||
type diskState string
|
||||
|
||||
const (
|
||||
diskStateNone diskState = "none" // no medium attached
|
||||
diskStateImage diskState = "image" // an image file is attached
|
||||
diskStatePartition diskState = "partition" // the whole data partition is attached
|
||||
diskStateUnsupported diskState = "unsupported" // no mass storage function (virtual disk off, or HID-only)
|
||||
)
|
||||
|
||||
// diskStatus is what the gadget exposes to the target machine right now. The
|
||||
// backing file is the single source of truth: both the UI and the file manager
|
||||
// read it instead of keeping their own idea of what is mounted.
|
||||
type diskStatus struct {
|
||||
State diskState
|
||||
Backing string
|
||||
HidOnly bool
|
||||
}
|
||||
|
||||
// lunConfig is the LUN configuration a mount request results in.
|
||||
type lunConfig struct {
|
||||
backing string
|
||||
ro bool
|
||||
cdrom bool
|
||||
}
|
||||
|
||||
func readDiskStatus() (diskStatus, error) {
|
||||
mode, err := hid.GetMode()
|
||||
hidOnly := err == nil && mode == hid.ModeHidOnly
|
||||
|
||||
// Switching the virtual disk off removes the link that exposes the
|
||||
// function to the target machine, while the function itself stays behind
|
||||
// with whatever medium was attached to it. The link is therefore what says
|
||||
// whether a medium would actually be visible, and without it a mount would
|
||||
// look like it worked and change nothing.
|
||||
if !pathExists(massStorageFunc) || !pathExists(massStorageLink) {
|
||||
return diskStatus{State: diskStateUnsupported, HidOnly: hidOnly}, nil
|
||||
}
|
||||
|
||||
config, err := readLunConfig()
|
||||
if err != nil {
|
||||
return diskStatus{}, err
|
||||
}
|
||||
|
||||
status := diskStatus{HidOnly: hidOnly}
|
||||
switch {
|
||||
case config.backing == "":
|
||||
status.State = diskStateNone
|
||||
case config.backing == partitionDevice:
|
||||
status.State = diskStatePartition
|
||||
status.Backing = config.backing
|
||||
default:
|
||||
status.State = diskStateImage
|
||||
status.Backing = config.backing
|
||||
// A running gadget may still report an alias used before this process
|
||||
// started. Normalize it too, so every operation compares the same
|
||||
// exFAT directory entry rather than just the same string.
|
||||
if canonical, err := utils.CanonicalMediaPath(config.backing); err == nil {
|
||||
status.Backing = canonical
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readLunConfig() (lunConfig, error) {
|
||||
backing, err := readLunAttr(lunMountDevice)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
ro, err := readLunBool(lunROFlag)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
cdrom, err := readLunBool(lunCDRomFlag)
|
||||
if err != nil {
|
||||
return lunConfig{}, err
|
||||
}
|
||||
|
||||
return lunConfig{backing: backing, ro: ro, cdrom: cdrom}, nil
|
||||
}
|
||||
|
||||
func readLunAttr(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func readLunBool(path string) (bool, error) {
|
||||
value, err := readLunAttr(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return value == "1", nil
|
||||
}
|
||||
|
||||
// eject detaches the medium, which the target machine sees as a media removal.
|
||||
func eject() error {
|
||||
// The medium goes first. While it is attached the kernel refuses ro but
|
||||
// takes cdrom=0 and a new name, so resetting those first would relabel a
|
||||
// medium the target machine still holds whenever the detach itself then
|
||||
// fails with EBUSY. A zero length write never reaches the kernel, so an
|
||||
// empty line is what actually detaches the medium.
|
||||
if err := writeLunAttr(lunMountDevice, "\n"); err != nil {
|
||||
if errors.Is(err, syscall.EBUSY) {
|
||||
return ErrMediumLocked
|
||||
}
|
||||
return fmt.Errorf("eject medium: %w", err)
|
||||
}
|
||||
|
||||
// The flags outlive the medium: the kernel only lets ro change while
|
||||
// nothing is attached, and a stale CD-ROM flag would describe the next
|
||||
// medium wrongly (a restart of the boot script resets them for the same
|
||||
// reason). The device also keeps the name it was built with while nothing
|
||||
// is attached.
|
||||
if err := writeLunAttr(lunInquiry, inquiryData("USB Mass Storage")); err != nil {
|
||||
log.Warnf("restore inquiry string failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(usbProductAttr, usbProductName); err != nil {
|
||||
log.Warnf("restore usb product failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunROFlag, boolAttr(false)); err != nil {
|
||||
log.Warnf("reset read-only flag failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunCDRomFlag, boolAttr(false)); err != nil {
|
||||
log.Warnf("reset cdrom flag failed: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// attach runs the sequence the kernel requires. The read-only flag can only be
|
||||
// written while no medium is attached, and both it and the CD-ROM flag force a
|
||||
// detach of their own, so the order is fixed and every flag is written on
|
||||
// every mount: leaving a stale cdrom=1 behind is how an image file ends up
|
||||
// presented to the target as a CD-ROM.
|
||||
func attach(backing string, cdrom, ro bool) error {
|
||||
if backing == "" {
|
||||
return errors.New("empty backing file")
|
||||
}
|
||||
if cdrom {
|
||||
// CD-ROM media is always read only.
|
||||
ro = true
|
||||
}
|
||||
|
||||
// The target machine is the only writer of the data partition while the
|
||||
// emulated disk serves it, so /data goes read-only here before it can write
|
||||
// it. An image is served read-only instead, which leaves /data to this
|
||||
// side, so that handover happens after the gadget carries the image.
|
||||
if backing == partitionDevice {
|
||||
if err := SetDataWritable(false); err != nil {
|
||||
return fmt.Errorf("make %s read-only: %w", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := eject(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeLunAttr(lunROFlag, boolAttr(ro)); err != nil {
|
||||
return fmt.Errorf("set read-only flag: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(lunCDRomFlag, boolAttr(cdrom)); err != nil {
|
||||
return fmt.Errorf("set cdrom flag: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(lunInquiry, inquiryData(mediumProduct(backing, cdrom))); err != nil {
|
||||
return fmt.Errorf("set inquiry string: %w", err)
|
||||
}
|
||||
if err := writeLunAttr(usbProductAttr, mediumDeviceName(backing)); err != nil {
|
||||
// The target machine is left with the previous device name instead of
|
||||
// failing the mount: the medium itself is what matters.
|
||||
log.Warnf("set usb product failed: %s", err)
|
||||
}
|
||||
if err := writeLunAttr(lunMountDevice, backing); err != nil {
|
||||
if errors.Is(err, syscall.EBUSY) {
|
||||
return ErrMediumLocked
|
||||
}
|
||||
return fmt.Errorf("attach %s: %w", backing, err)
|
||||
}
|
||||
|
||||
if err := rebindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if backing != partitionDevice {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// mediumProduct is the SCSI product string of the medium: an image is shown
|
||||
// under its file name, the mount mode names the medium otherwise.
|
||||
func mediumProduct(backing string, cdrom bool) string {
|
||||
if name := imageMediumName(backing); name != "" {
|
||||
return cutName(name, inquiryProductSize)
|
||||
}
|
||||
if cdrom {
|
||||
return "USB CD/DVD-ROM"
|
||||
}
|
||||
|
||||
return "USB Mass Storage"
|
||||
}
|
||||
|
||||
// mediumDeviceName is the USB product string, which the target machine shows
|
||||
// as the device name. The shared partition keeps the one it was built with.
|
||||
func mediumDeviceName(backing string) string {
|
||||
if name := imageMediumName(backing); name != "" {
|
||||
return cutName(name, usbProductSize)
|
||||
}
|
||||
|
||||
return usbProductName
|
||||
}
|
||||
|
||||
// imageMediumName is the name a mounted image is shown under: its file name
|
||||
// without the extension. The raw partition is not an image.
|
||||
func imageMediumName(backing string) string {
|
||||
if backing == partitionDevice {
|
||||
return ""
|
||||
}
|
||||
|
||||
return imageName(backing)
|
||||
}
|
||||
|
||||
// imageName strips the extension from the file name of a medium.
|
||||
func imageName(path string) string {
|
||||
name := filepath.Base(path)
|
||||
if extension := filepath.Ext(name); extension != "" {
|
||||
name = strings.TrimSuffix(name, extension)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// cutName cuts name to size bytes without leaving a broken UTF-8 rune behind:
|
||||
// the inquiry field and the USB product string are both byte limited.
|
||||
func cutName(name string, size int) string {
|
||||
if len(name) <= size {
|
||||
return name
|
||||
}
|
||||
|
||||
name = name[:size]
|
||||
for !utf8.ValidString(name) {
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func writeLunAttr(path, value string) error {
|
||||
if err := os.WriteFile(path, []byte(value), 0o666); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolAttr(value bool) string {
|
||||
if value {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func inquiryData(product string) string {
|
||||
return fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", product, 0x0520)
|
||||
}
|
||||
|
||||
// unbindUDC tears the gadget down. The kernel clears the medium lock the
|
||||
// target machine set (prevent_medium_removal) whenever the function is
|
||||
// reconfigured, so this is what makes a forced eject possible: without it the
|
||||
// kernel refuses to touch the medium while the target machine is holding it.
|
||||
func unbindUDC() error {
|
||||
if err := writeLunAttr(gadgetUDC, "\n"); err != nil {
|
||||
return fmt.Errorf("unbind UDC: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// rebindUDC re-enumerates the gadget. It drops and re-adds every function,
|
||||
// keyboard and mouse included, which is why callers skip it when nothing
|
||||
// changed.
|
||||
func rebindUDC() error {
|
||||
// The forced path unbinds the gadget first, so it can already be unbound
|
||||
// here; the kernel answers that with ENODEV instead of doing anything.
|
||||
if err := writeLunAttr(gadgetUDC, "\n"); err != nil && !errors.Is(err, syscall.ENODEV) {
|
||||
return fmt.Errorf("unbind UDC: %w", err)
|
||||
}
|
||||
|
||||
udc, err := firstUDC()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := writeLunAttr(gadgetUDC, udc+"\n"); err != nil {
|
||||
return fmt.Errorf("bind UDC %s: %w", udc, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstUDC() (string, error) {
|
||||
entries, err := os.ReadDir(gadgetUDCDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list UDC: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
return entry.Name(), nil
|
||||
}
|
||||
return "", errors.New("no UDC available")
|
||||
}
|
||||
|
||||
// withHIDLock closes the HID devices around a gadget operation, because
|
||||
// re-enumerating drops them. The reopen is retried and never turns a
|
||||
// successful operation into a failure: builds without HID (/boot/disable_hid)
|
||||
// have nothing to open at all.
|
||||
func withHIDLock(fn func() error) error {
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer func() {
|
||||
hid.ReopenAfterRebind()
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
return fn()
|
||||
}
|
||||
@@ -1,66 +1,42 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
imageDirectory = "/data"
|
||||
imageNone = "/dev/mmcblk0p3"
|
||||
cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom"
|
||||
mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file"
|
||||
inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string"
|
||||
roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro"
|
||||
usbProduct = "/sys/kernel/config/usb_gadget/g0/strings/0x409/product"
|
||||
|
||||
usbProductName = "NanoKVM"
|
||||
// the SCSI inquiry product field holds 16 bytes, the USB product string 126
|
||||
inquiryProductSize = 16
|
||||
usbProductSize = 126
|
||||
)
|
||||
// The handlers in this file are the pre-2.6 image API. The web UI uses the
|
||||
// media API instead, but these routes stay so scripts written against the
|
||||
// documented endpoints keep working: same paths, same request shapes, same
|
||||
// responses. They share the media implementation underneath, which is also
|
||||
// where the guards they used to lack now live.
|
||||
|
||||
func (s *Service) GetImages(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
var images []string
|
||||
|
||||
err := filepath.Walk(imageDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
name := strings.ToLower(info.Name())
|
||||
if strings.HasSuffix(name, ".iso") || strings.HasSuffix(name, ".img") {
|
||||
images = append(images, path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
media, err := listMedia()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "get images failed")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetImagesRsp{
|
||||
Files: images,
|
||||
})
|
||||
log.Debugf("get images success, total %d", len(images))
|
||||
files := make([]string, 0, len(media))
|
||||
for _, item := range media {
|
||||
if item.Type == proto.MediaTypeImage {
|
||||
files = append(files, filepath.Join(mediaDirectory, filepath.FromSlash(item.Rel)))
|
||||
}
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetImagesRsp{Files: files})
|
||||
log.Debugf("get images success, total %d", len(files))
|
||||
}
|
||||
|
||||
func (s *Service) MountImage(c *gin.Context) {
|
||||
@@ -68,125 +44,41 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
// The host owns the raw partition while the virtual disk serves it, so /data
|
||||
// has to be read-only here before the host can write it.
|
||||
if req.File == "" {
|
||||
if err := SetDataWritable(false); err != nil {
|
||||
log.Errorf("make %s read-only failed: %s", imageDirectory, err)
|
||||
rsp.ErrRsp(c, -2, "make /data read-only failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// set the LUN flags, unmounting first so no image is ever served writable
|
||||
if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil {
|
||||
log.Errorf("unmount file failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "unmount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
// ro flag
|
||||
// set to 0 when unmount image
|
||||
// set to 1 when mount image, the host can only read the image
|
||||
roFlagValue := "0"
|
||||
// An empty file keeps its original meaning: attach the whole data
|
||||
// partition, which is what the emulated disk exposes by default.
|
||||
backing := partitionDevice
|
||||
if req.File != "" {
|
||||
roFlagValue = "1"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(roFlag, []byte(roFlagValue), 0o666); err != nil {
|
||||
log.Errorf("set ro flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set ro flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
// cdrom flag
|
||||
// set to 0 when unmount image
|
||||
// set to 1 when mount image and the CD-ROM is enabled
|
||||
cdromFlagValue := "0"
|
||||
if req.File != "" && req.Cdrom {
|
||||
cdromFlagValue = "1"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cdromFlag, []byte(cdromFlagValue), 0o666); err != nil {
|
||||
log.Errorf("set cdrom flag failed: %s", err)
|
||||
rsp.ErrRsp(c, -2, "set cdrom flag failed")
|
||||
return
|
||||
}
|
||||
|
||||
inquiryVen := "NanoKVM"
|
||||
inquiryPrd := "USB Mass Storage"
|
||||
inquiryVer := 0x0520
|
||||
if req.Cdrom {
|
||||
inquiryPrd = "USB CD/DVD-ROM"
|
||||
}
|
||||
|
||||
// the image name is what the host shows for the device and for the medium,
|
||||
// the SCSI inquiry is the medium and the USB product string the device itself
|
||||
productName := usbProductName
|
||||
if req.File != "" {
|
||||
if name := imageName(req.File); name != "" {
|
||||
inquiryPrd = cutName(name, inquiryProductSize)
|
||||
productName = cutName(name, usbProductSize)
|
||||
}
|
||||
}
|
||||
|
||||
inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer)
|
||||
|
||||
if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil {
|
||||
log.Errorf("set inquiry %s failed: %s", inquiryData, err)
|
||||
rsp.ErrRsp(c, -2, "set inquiry failed")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(usbProduct, []byte(productName), 0o666); err != nil {
|
||||
log.Errorf("set usb product %s failed: %s", productName, err)
|
||||
}
|
||||
|
||||
// mount
|
||||
image := req.File
|
||||
if image == "" {
|
||||
image = imageNone
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil {
|
||||
log.Errorf("mount file %s failed: %s", image, err)
|
||||
rsp.ErrRsp(c, -2, "mount image failed")
|
||||
return
|
||||
}
|
||||
|
||||
// An image is served read-only, so the KVM writes /data itself again. The
|
||||
// host can not write the image either way.
|
||||
if req.File != "" {
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", imageDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
h := hid.GetHid()
|
||||
h.Lock()
|
||||
h.CloseNoLock()
|
||||
defer func() {
|
||||
h.OpenNoLock()
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
// reset usb
|
||||
commands := []string{
|
||||
"echo > /sys/kernel/config/usb_gadget/g0/UDC",
|
||||
"ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC",
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
err := exec.Command("sh", "-c", command).Run()
|
||||
rel, err := legacyImageRel(req.File)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "execute command failed")
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
backing, err = utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Cdrom {
|
||||
kind, err := diskfs.Detect(backing)
|
||||
if err != nil || !kind.Optical() {
|
||||
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.File))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An image is always served read-only, the raw partition is not.
|
||||
ro := req.Cdrom || req.File != ""
|
||||
|
||||
if err := s.attachMedium(backing, req.Cdrom, ro, false); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
@@ -196,132 +88,37 @@ func (s *Service) MountImage(c *gin.Context) {
|
||||
func (s *Service) GetMountedImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode, err := hid.GetMode()
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "get HID mode failed")
|
||||
rsp.ErrRsp(c, codeOperationFailed, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
if mode == hid.ModeHidOnly {
|
||||
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{
|
||||
File: "",
|
||||
})
|
||||
return
|
||||
// The data partition is reported as "nothing mounted", the way this
|
||||
// endpoint has always answered.
|
||||
file := ""
|
||||
if status.State == diskStateImage {
|
||||
file = status.Backing
|
||||
}
|
||||
|
||||
image, err := MountedImage()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
data := &proto.GetMountedImageRsp{
|
||||
File: image,
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
}
|
||||
|
||||
// MountedImage returns the mounted image file, empty if no image is mounted
|
||||
func MountedImage() (string, error) {
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
image := strings.ReplaceAll(string(content), "\n", "")
|
||||
if image == imageNone {
|
||||
image = ""
|
||||
}
|
||||
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// imageName returns the file name of an image without the extension
|
||||
func imageName(file string) string {
|
||||
return strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
}
|
||||
|
||||
// cutName cuts a name to size bytes without splitting a character
|
||||
func cutName(name string, size int) string {
|
||||
if len(name) <= size {
|
||||
return name
|
||||
}
|
||||
|
||||
name = name[:size]
|
||||
for !utf8.ValidString(name) {
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// SetDataWritable remounts /data read-write or read-only. Only one side may
|
||||
// write the partition at a time: while the virtual disk shares it with the host
|
||||
// as a writable disk, /data is read-only here, and it becomes writable again
|
||||
// once the share is released or an image is served read-only instead.
|
||||
func SetDataWritable(writable bool) error {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &stat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (stat.Flags&unix.ST_RDONLY == 0) == writable {
|
||||
return nil
|
||||
}
|
||||
|
||||
option := "rw"
|
||||
if !writable {
|
||||
option = "ro"
|
||||
}
|
||||
|
||||
if err := exec.Command("mount", "-o", "remount,"+option, imageDirectory).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// never hand a writable /data to the host
|
||||
var check unix.Statfs_t
|
||||
if err := unix.Statfs(imageDirectory, &check); err != nil {
|
||||
return err
|
||||
}
|
||||
if (check.Flags&unix.ST_RDONLY == 0) != writable {
|
||||
return errors.New(imageDirectory + " mount did not change")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataDiskAttached reports whether the mass storage device serves the whole /data partition to the host
|
||||
func DataDiskAttached() bool {
|
||||
content, err := os.ReadFile(mountDevice)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(string(content), "\n", "") == imageNone
|
||||
rsp.OkRspWithData(c, &proto.GetMountedImageRsp{File: file})
|
||||
}
|
||||
|
||||
func (s *Service) GetCdRom(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
content, err := os.ReadFile(cdromFlag)
|
||||
cdrom, err := readLunBool(lunCDRomFlag)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "read failed")
|
||||
rsp.ErrRsp(c, codeOperationFailed, "read failed")
|
||||
return
|
||||
}
|
||||
|
||||
flag := strings.ReplaceAll(string(content), "\n", "")
|
||||
flatInt, err := strconv.ParseInt(flag, 10, 64)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -2, "parse failed")
|
||||
return
|
||||
flag := int64(0)
|
||||
if cdrom {
|
||||
flag = 1
|
||||
}
|
||||
|
||||
data := &proto.GetCdRomRsp{
|
||||
Cdrom: flatInt,
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
rsp.OkRspWithData(c, &proto.GetCdRomRsp{Cdrom: flag})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteImage(c *gin.Context) {
|
||||
@@ -329,25 +126,45 @@ func (s *Service) DeleteImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
filename := strings.ToLower(req.File)
|
||||
validPrefix := strings.HasPrefix(filename, imageDirectory)
|
||||
validSuffix := strings.HasSuffix(filename, ".iso") || strings.HasSuffix(filename, ".img")
|
||||
|
||||
if !validPrefix || !validSuffix {
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
rel, err := legacyImageRel(req.File)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(req.File); err != nil {
|
||||
rsp.ErrRsp(c, -3, "remove file failed")
|
||||
log.Errorf("failed to remove file %s: %s", req.File, err)
|
||||
target, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteMedium(target); err != nil {
|
||||
log.Errorf("failed to remove file %s: %s", target, err)
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete image %s success", req.File)
|
||||
}
|
||||
|
||||
// legacyImageRel turns the absolute path this API has always used into a
|
||||
// medium id. The old validation was a string prefix check, which also accepted
|
||||
// "/dataevil.img" and "/data/../../etc/passwd.iso"; resolving the path
|
||||
// properly closes both.
|
||||
func legacyImageRel(file string) (string, error) {
|
||||
if !filepath.IsAbs(file) {
|
||||
return "", fmt.Errorf("%w: %s is not an absolute path", utils.ErrInvalidMediaID, file)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(mediaDirectory, filepath.Clean(file))
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: %s is outside %s", utils.ErrInvalidMediaID, file, mediaDirectory)
|
||||
}
|
||||
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
151
server/service/storage/job.go
Normal file
151
server/service/storage/job.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
type jobState string
|
||||
|
||||
const (
|
||||
jobStateIdle jobState = "idle"
|
||||
jobStateInProgress jobState = "in_progress"
|
||||
jobStateSuccess jobState = "success"
|
||||
jobStateFailed jobState = "failed"
|
||||
)
|
||||
|
||||
// jobProgressInterval matches the ticker the downloader reports with, so the
|
||||
// web UI polls every transfer the same way.
|
||||
const jobProgressInterval = 2500 * time.Millisecond
|
||||
|
||||
// maxQueuedJobs bounds the queue: the transfers hold their HTTP connections
|
||||
// open while they wait, so an unbounded queue would let one client hoard them.
|
||||
const maxQueuedJobs = 8
|
||||
|
||||
var errTooManyJobs = errors.New("too many transfers are already queued")
|
||||
|
||||
// activeJobs counts the file manager transfers holding a job slot, across
|
||||
// every Service, so code outside the handlers can tell whether a transfer
|
||||
// temporary may still be written.
|
||||
var activeJobs atomic.Int32
|
||||
|
||||
// TransferInProgress reports whether any transfer that writes a temporary
|
||||
// file can be running: a file manager upload holds a job slot, the image
|
||||
// downloader and the updater hold the transfer lock file. A temporary found
|
||||
// while this is false was left behind by an interrupted transfer.
|
||||
func TransferInProgress() bool {
|
||||
return activeJobs.Load() > 0 || pathExists(utils.TransferSentinelPath)
|
||||
}
|
||||
|
||||
// activeJobTarget returns the path the running upload writes, if any.
|
||||
func (s *Service) activeJobTarget() (string, bool) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
return s.jobName, s.jobDone != nil
|
||||
}
|
||||
|
||||
// beginJob claims the single media job slot, queueing behind whatever is
|
||||
// running instead of refusing: the user asked for a file to be written, and
|
||||
// waiting their turn is what they expect. The queue is first come, first
|
||||
// served, and a request whose client goes away leaves it.
|
||||
//
|
||||
// The transfer lock file that coordinates with the image downloader and the
|
||||
// application updater is deliberately not taken here. Writing into an image
|
||||
// only touches blocks that image already owns, so it neither races the
|
||||
// downloader nor holds it up.
|
||||
func (s *Service) beginJob(ctx context.Context, name string) (chan struct{}, error) {
|
||||
for {
|
||||
s.jobMutex.Lock()
|
||||
|
||||
if s.jobDone == nil {
|
||||
done := make(chan struct{})
|
||||
s.jobDone = done
|
||||
s.jobState = jobStateInProgress
|
||||
s.jobName = name
|
||||
s.jobLoaded = 0
|
||||
s.jobTotal = 0
|
||||
s.jobMessage = ""
|
||||
activeJobs.Add(1)
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
return done, nil
|
||||
}
|
||||
|
||||
if len(s.jobWaiters) >= maxQueuedJobs {
|
||||
s.jobMutex.Unlock()
|
||||
return nil, errTooManyJobs
|
||||
}
|
||||
|
||||
waiter := make(chan struct{})
|
||||
s.jobWaiters = append(s.jobWaiters, waiter)
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-waiter:
|
||||
// Our turn; the loop tries to claim the slot.
|
||||
case <-ctx.Done():
|
||||
s.abandonWaiter(waiter)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// abandonWaiter drops a waiter whose client went away. A waiter that is still
|
||||
// queued only has to leave the queue; one that was already handed the turn
|
||||
// (finishJob closed its channel) has to pass it on, or the transfers waiting
|
||||
// behind it would sit there until their own clients gave up too.
|
||||
func (s *Service) abandonWaiter(waiter chan struct{}) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
for i, queued := range s.jobWaiters {
|
||||
if queued == waiter {
|
||||
s.jobWaiters = append(s.jobWaiters[:i], s.jobWaiters[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s.jobDone == nil && len(s.jobWaiters) > 0 {
|
||||
close(s.jobWaiters[0])
|
||||
s.jobWaiters = s.jobWaiters[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setJobProgress(done chan struct{}, loaded, total int64) {
|
||||
s.jobMutex.Lock()
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
if s.jobDone == done {
|
||||
s.jobLoaded = loaded
|
||||
s.jobTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finishJob(done chan struct{}, state jobState, message string) {
|
||||
s.jobMutex.Lock()
|
||||
if s.jobDone != done {
|
||||
s.jobMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.jobDone = nil
|
||||
activeJobs.Add(-1)
|
||||
s.jobState = state
|
||||
s.jobLoaded = 0
|
||||
s.jobTotal = 0
|
||||
s.jobMessage = message
|
||||
|
||||
// The slot goes to whoever queued first.
|
||||
if len(s.jobWaiters) > 0 {
|
||||
close(s.jobWaiters[0])
|
||||
s.jobWaiters = s.jobWaiters[1:]
|
||||
}
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
close(done)
|
||||
}
|
||||
861
server/service/storage/media.go
Normal file
861
server/service/storage/media.go
Normal file
@@ -0,0 +1,861 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/storage/diskfs"
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
// Error codes the web UI reacts to. The response envelope is shared with every
|
||||
// other endpoint, so these stay inside the media API's own range: the UI needs
|
||||
// to tell "the target machine is still holding the medium" apart from "you
|
||||
// have to enable the virtual disk first", and neither apart from a plain
|
||||
// failure.
|
||||
const (
|
||||
codeInvalidArgument = -1
|
||||
codeOperationFailed = -2
|
||||
codeStorageDisabled = -10
|
||||
codeMediumLocked = -11
|
||||
codeMediumBusy = -12
|
||||
codeNotBrowsable = -13
|
||||
codeInsufficientSpace = -14
|
||||
codeNotOptical = -15
|
||||
)
|
||||
|
||||
// partitionDevice is the whole data partition. It is what the emulated disk
|
||||
// exposes by default, and while it is attached every medium is at the mercy of
|
||||
// the target machine, because every medium lives on it.
|
||||
const partitionDevice = "/dev/mmcblk0p3"
|
||||
|
||||
// mediaDirectory holds the media. It is the same directory the virtual disk
|
||||
// hands to the target machine, so nothing secret belongs in it.
|
||||
const mediaDirectory = utils.MediaDirectory
|
||||
|
||||
// mediumOperationMu serializes the two things that must not interleave: the
|
||||
// file manager opening a medium and the gadget serving one.
|
||||
var mediumOperationMu sync.Mutex
|
||||
|
||||
// partitionWriteRefs counts long-running writes that land directly on the
|
||||
// data partition. The counter alone is atomic because a handle releases it
|
||||
// after openMedium has returned; taking the reference still happens under
|
||||
// mediumOperationMu, together with the gadget-state check, so a mount cannot
|
||||
// slip between the two.
|
||||
var partitionWriteRefs atomic.Int64
|
||||
|
||||
// LockVirtualDiskTransition serializes a gadget transition with the media API.
|
||||
// It applies to disk and network changes because both restart the gadget and
|
||||
// can make the boot script expose the raw partition. The returned function
|
||||
// releases the lock and must be deferred by the caller. A transition is refused
|
||||
// while the file manager holds an image: exposing the raw partition would
|
||||
// otherwise let the target machine overwrite the image underneath its loop
|
||||
// device.
|
||||
func LockVirtualDiskTransition() (func(), error) {
|
||||
mediumOperationMu.Lock()
|
||||
if err := ensureNoOpenImages(); err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if partitionWriteRefs.Load() > 0 {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, errMediumBusy
|
||||
}
|
||||
|
||||
return mediumOperationMu.Unlock, nil
|
||||
}
|
||||
|
||||
func ensureNoOpenImages() error {
|
||||
if len(diskfs.MountedImages()) > 0 {
|
||||
return errMediumBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errStorageDisabled = errors.New("the virtual disk is disabled on this device")
|
||||
errPartitionOwned = errors.New("the data partition is attached to the target machine")
|
||||
errMediumAttached = errors.New("the medium is attached to the target machine")
|
||||
errMediumBusy = errors.New("the medium is in use by the file manager")
|
||||
errNoSpace = errors.New("not enough space on the data partition")
|
||||
errNotOptical = errors.New("a CD-ROM can only carry an ISO 9660 or UDF image")
|
||||
errNotAMedium = errors.New("the data partition has no file manager of its own")
|
||||
)
|
||||
|
||||
func (s *Service) GetMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
media, err := listMedia()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
caps := diskfs.Probe()
|
||||
rsp.OkRspWithData(c, &proto.GetMediaRsp{
|
||||
Media: media,
|
||||
Caps: proto.MediaCaps{Loop: caps.Loop},
|
||||
})
|
||||
|
||||
log.Debugf("get media success, total %d", len(media))
|
||||
}
|
||||
|
||||
func (s *Service) GetMediaState(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// With the virtual disk off the LUN may not exist at all, and that state
|
||||
// is exactly what the web UI needs to see to offer turning it on.
|
||||
cdrom := false
|
||||
if status.State != diskStateUnsupported {
|
||||
cdrom, err = readLunBool(lunCDRomFlag)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
state := &proto.MediaStateRsp{
|
||||
State: string(status.State),
|
||||
HidOnly: status.HidOnly,
|
||||
Open: openMediaIDs(),
|
||||
Cdrom: cdrom,
|
||||
}
|
||||
|
||||
switch status.State {
|
||||
case diskStateImage:
|
||||
state.Type = proto.MediaTypeImage
|
||||
state.Rel = mediaRel(status.Backing)
|
||||
case diskStatePartition:
|
||||
state.Type = proto.MediaTypePartition
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, state)
|
||||
}
|
||||
|
||||
func (s *Service) GetMediaStatus(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
s.jobMutex.Lock()
|
||||
data := &proto.MediaStatusRsp{
|
||||
Status: string(s.jobState),
|
||||
Name: s.jobName,
|
||||
Percentage: utils.FormatPercentage(s.jobLoaded, s.jobTotal),
|
||||
Loaded: s.jobLoaded,
|
||||
Total: s.jobTotal,
|
||||
Message: s.jobMessage,
|
||||
}
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
rsp.OkRspWithData(c, data)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMedia(c *gin.Context) {
|
||||
var req proto.MediaRefReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
if req.Type == proto.MediaTypePartition {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "the data partition cannot be deleted")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(req.Rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := deleteMedium(backing); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete medium %s success", req.Rel)
|
||||
}
|
||||
|
||||
// deleteMedium keeps the state checks and the unlink in the same critical
|
||||
// section as gadget attachment. Otherwise a concurrent mount can attach the
|
||||
// file after the check and leave the gadget reading a deleted inode.
|
||||
func deleteMedium(backing string) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
return errMediumAttached
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return errPartitionOwned
|
||||
}
|
||||
if diskfs.IsMounted(backing) {
|
||||
return errMediumBusy
|
||||
}
|
||||
|
||||
if err := os.Remove(backing); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", backing, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MountMedia(c *gin.Context) {
|
||||
var req proto.MountMediaReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := resolveBacking(req.Type, req.Rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
if status.State == diskStateUnsupported {
|
||||
respondError(c, errStorageDisabled)
|
||||
return
|
||||
}
|
||||
if diskfs.IsMounted(backing) {
|
||||
respondError(c, errMediumBusy)
|
||||
return
|
||||
}
|
||||
|
||||
// A CD-ROM is an optical disc, so its medium has to hold a disc
|
||||
// filesystem (ISO 9660 or UDF): the gadget then reports 2048 byte sectors,
|
||||
// and a partition or any other filesystem read that way is what the target
|
||||
// machine reports as a corrupt disc.
|
||||
if req.Cdrom {
|
||||
kind, err := diskfs.Detect(backing)
|
||||
if err != nil || !kind.Optical() {
|
||||
respondError(c, fmt.Errorf("%w: %s", errNotOptical, req.Rel))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An image is always served read-only: the target machine must not write the
|
||||
// file this side browses, and a read-only medium is what lets this side keep
|
||||
// writing /data while it is attached.
|
||||
ro := req.Type != proto.MediaTypePartition || req.Cdrom
|
||||
if err := s.attachMedium(backing, req.Cdrom, ro, req.Force); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("mount medium %s (%s) success, cdrom=%v ro=%v", backing, req.Type, req.Cdrom, ro)
|
||||
}
|
||||
|
||||
func (s *Service) EjectMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// The body is optional: without it this is an ordinary eject, which fails
|
||||
// when the target machine holds the medium, and the caller retries with
|
||||
// force after asking the user.
|
||||
var req proto.EjectMediaReq
|
||||
_ = proto.ParseFormRequest(c, &req)
|
||||
|
||||
if err := s.detachMedium(req.Force); err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("eject medium success")
|
||||
}
|
||||
|
||||
// attachMedium is the whole mount path: refuse a relabeling that changes
|
||||
// nothing, then run the kernel's required sequence with the HID devices closed.
|
||||
func (s *Service) attachMedium(backing string, cdrom, ro, force bool) error {
|
||||
// Mounting here and serving the medium to the target machine are two sides
|
||||
// of one decision: without the lock, an upload into an image can be opening
|
||||
// it read-write while the gadget starts serving the same file.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
if backing == partitionDevice {
|
||||
// Every image lives on the partition. Handing that partition to the
|
||||
// target while any image has a loop mount would give both sides the
|
||||
// same blocks, even though the partition itself is not in mounts. A
|
||||
// direct partition write has no loop mount to check, so it carries an
|
||||
// explicit reference instead.
|
||||
if err := ensureNoOpenImages(); err != nil {
|
||||
return err
|
||||
}
|
||||
if partitionWriteRefs.Load() > 0 {
|
||||
return errMediumBusy
|
||||
}
|
||||
} else if diskfs.IsMounted(backing) {
|
||||
return errMediumBusy
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Attaching what the target machine already has is a no-op: running the
|
||||
// sequence again would remove the medium and re-enumerate the gadget under
|
||||
// a machine that is using it - and fail with EBUSY if it holds the disk.
|
||||
if status.Backing != "" && status.Backing == backing {
|
||||
config, err := readLunConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.cdrom == cdrom && config.ro == ro {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err = withHIDLock(func() error {
|
||||
err := attach(backing, cdrom, ro)
|
||||
if errors.Is(err, ErrMediumLocked) && force {
|
||||
// The target machine holds the medium; re-enumerating the gadget
|
||||
// is the only way to take it back without its cooperation.
|
||||
if err := unbindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = attach(backing, cdrom, ro)
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
// attach may have changed /data before a later step failed. Base the
|
||||
// rollback on the state that was actually left behind, not on the
|
||||
// state in which the transition started.
|
||||
if restoreErr := restoreDataAfterFailedAttach(); restoreErr != nil {
|
||||
return errors.Join(err, restoreErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func restoreDataAfterFailedAttach() error {
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read medium state after failed mount: %w", err)
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
return nil
|
||||
}
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
return fmt.Errorf("restore %s writable after failed mount: %w", mediaDirectory, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) detachMedium(force bool) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.State == diskStateUnsupported {
|
||||
return errStorageDisabled
|
||||
}
|
||||
if status.State == diskStateNone {
|
||||
// Nothing is attached, so there is nothing to release and nothing to
|
||||
// re-enumerate.
|
||||
return nil
|
||||
}
|
||||
|
||||
release := func() error {
|
||||
if err := eject(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Nothing is served any more, so the data partition belongs to this
|
||||
// side again.
|
||||
releaseDataPartition()
|
||||
return rebindUDC()
|
||||
}
|
||||
|
||||
err = withHIDLock(release)
|
||||
if errors.Is(err, ErrMediumLocked) && force {
|
||||
err = withHIDLock(func() error {
|
||||
if err := unbindUDC(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The lock went away with the re-enumeration, so the medium can be
|
||||
// detached now. The gadget is bound again either way: leaving it
|
||||
// unbound would cost the target machine its keyboard, mouse and
|
||||
// disk until something else rebinds it.
|
||||
detachErr := eject()
|
||||
releaseDataPartition()
|
||||
bindErr := rebindUDC()
|
||||
if detachErr != nil {
|
||||
return detachErr
|
||||
}
|
||||
return bindErr
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DownloadMedia(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
rel := c.Query("rel")
|
||||
if rel == "" || c.Query("type") == proto.MediaTypePartition {
|
||||
rsp.ErrRsp(c, codeInvalidArgument, "a disk image is required")
|
||||
return
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
// Downloading is reading, and a read-only data partition is still readable:
|
||||
// the target machine owning the partition does not stop this side from
|
||||
// streaming an image file out of it. Its contents may change while it is
|
||||
// being served, which is the same caveat the file browser has.
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
respondError(c, errMediumAttached)
|
||||
return
|
||||
}
|
||||
// A file manager write may be in flight; streaming the image now would
|
||||
// hand out a file that changes underneath the download. The pin refuses a
|
||||
// writable mount for the whole stream, so the check cannot go stale between
|
||||
// here and the open the way a one-shot IsMounted lookup did.
|
||||
releasePin, err := diskfs.AcquireReadPin(backing)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer releasePin()
|
||||
|
||||
file, err := os.Open(backing)
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
respondError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", attachmentHeader(filepath.Base(backing)))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// listMedia returns every image in the media directory plus the data partition
|
||||
// itself, which stays listed because exposing it over the emulated disk is the
|
||||
// documented way to drop an image onto the device.
|
||||
func listMedia() ([]proto.MediaItem, error) {
|
||||
// Sizes and names under /data can be written by the target machine at any
|
||||
// time; make the walk see them.
|
||||
invalidatePartitionCache()
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
images, err := findImages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
media := make([]proto.MediaItem, 0, len(images)+1)
|
||||
for _, path := range images {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
kind, _ := diskfs.Detect(path)
|
||||
media = append(media, proto.MediaItem{
|
||||
Type: proto.MediaTypeImage,
|
||||
Rel: mediaRel(path),
|
||||
Name: filepath.Base(path),
|
||||
Size: uint64(info.Size()),
|
||||
FS: string(kind),
|
||||
Browsable: kind.Browsable(),
|
||||
Attached: status.Backing == path,
|
||||
Modified: info.ModTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// The partition is what the emulated disk exposes by default, so it is the
|
||||
// medium a user most often has something to fetch from.
|
||||
media = append(media, proto.MediaItem{
|
||||
Type: proto.MediaTypePartition,
|
||||
Name: "Shared folder",
|
||||
FS: "exfat",
|
||||
Browsable: true,
|
||||
Attached: status.State == diskStatePartition,
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// findImages lists what the web UI offers to mount: every .iso and .img on the
|
||||
// volume, wherever it was put - uploaded here, copied over by the target
|
||||
// machine, or left by an older version of this feature.
|
||||
func findImages() ([]string, error) {
|
||||
var images []string
|
||||
|
||||
err := filepath.Walk(mediaDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// A device whose data partition was never created has no media,
|
||||
// which is not worth failing the whole listing over.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && utils.IsMediaFile(info.Name()) {
|
||||
images = append(images, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list media: %w", err)
|
||||
}
|
||||
|
||||
sort.Strings(images)
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// resolveBacking maps a request's medium id to the file the gadget would use.
|
||||
func resolveBacking(mediaType, rel string) (string, error) {
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
return partitionDevice, nil
|
||||
}
|
||||
return utils.ResolveMediaID(rel)
|
||||
}
|
||||
|
||||
// mediaRel is the inverse of ResolveMediaID, for reporting a backing file back
|
||||
// to the web UI.
|
||||
func mediaRel(backing string) string {
|
||||
return strings.TrimPrefix(backing, mediaDirectory+"/")
|
||||
}
|
||||
|
||||
func openMediaIDs() []string {
|
||||
mounted := diskfs.MountedImages()
|
||||
ids := make([]string, 0, len(mounted))
|
||||
for _, path := range mounted {
|
||||
ids = append(ids, mediaRel(path))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// InstallImage atomically replaces destPath with a downloaded or uploaded
|
||||
// image. The same lock used by gadget and file-manager operations covers both
|
||||
// the checks and the rename, so the destination cannot become attached or
|
||||
// loop-mounted in between.
|
||||
func InstallImage(tempPath, destPath string) error {
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
canonicalDest, err := utils.CanonicalMediaPath(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath = canonicalDest
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return installImage(tempPath, destPath, status, diskfs.IsMounted(destPath))
|
||||
}
|
||||
|
||||
func installImage(tempPath, destPath string, status diskStatus, mounted bool) error {
|
||||
if status.State == diskStatePartition {
|
||||
return ErrDataPartitionInUse
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == destPath {
|
||||
return fmt.Errorf("%s is attached to the target machine; eject it there first", filepath.Base(destPath))
|
||||
}
|
||||
if mounted {
|
||||
return fmt.Errorf("%w: %s", errMediumBusy, filepath.Base(destPath))
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install image %s: %w", filepath.Base(destPath), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openMedium returns a handle for a medium that is safe to touch. Everything
|
||||
// that reads or writes a medium's filesystem goes through here, so the two
|
||||
// ways to corrupt an image - writing it while the target machine owns it, and
|
||||
// writing it while the whole data partition is attached - are refused in one
|
||||
// place.
|
||||
func openMedium(mediaType, rel string, writable bool) (diskfs.Image, error) {
|
||||
// The check and the mount are one step: the gadget must not start serving a
|
||||
// medium between the two, or this side ends up writing a filesystem the
|
||||
// target machine is reading.
|
||||
mediumOperationMu.Lock()
|
||||
defer mediumOperationMu.Unlock()
|
||||
|
||||
return openMediumLocked(mediaType, rel, writable)
|
||||
}
|
||||
|
||||
// openMediumLocked is openMedium for a caller that already holds
|
||||
// mediumOperationMu. Keeping the lock until a subsequent operation finishes
|
||||
// is necessary when a check alone does not protect that operation, as with a
|
||||
// file delete through the shared partition.
|
||||
func openMediumLocked(mediaType, rel string, writable bool) (diskfs.Image, error) {
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A disabled virtual disk (diskStateUnsupported) is not refused here: with
|
||||
// nothing shared, the data partition is this side's alone and can be
|
||||
// browsed as usual, and an image needs no gadget to be mounted here.
|
||||
//
|
||||
// While the data partition is attached, the target machine owns every
|
||||
// medium - they all live on it - so nothing from here may write into one:
|
||||
// the two sides cache the exFAT allocation tables independently, and a
|
||||
// write from this side can hand the target machine clusters it is already
|
||||
// using. Reading is fine and needs no such care.
|
||||
if writable && status.State == diskStatePartition {
|
||||
return nil, errPartitionOwned
|
||||
}
|
||||
|
||||
if mediaType == proto.MediaTypePartition {
|
||||
// The partition needs no loop device: the emulated disk already hands
|
||||
// this very directory to the target machine. A writable handle has to
|
||||
// outlive this mutex: without the reference, a mount could take the
|
||||
// partition away after open and interrupt the write it returned for.
|
||||
image, err := diskfs.OpenDirectory(mediaDirectory)
|
||||
if err != nil || !writable {
|
||||
return image, err
|
||||
}
|
||||
partitionWriteRefs.Add(1)
|
||||
return &dataPartitionWriteHandle{Image: image}, nil
|
||||
}
|
||||
|
||||
backing, err := utils.ResolveMediaID(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status.State == diskStateImage && status.Backing == backing {
|
||||
return nil, errMediumAttached
|
||||
}
|
||||
|
||||
return diskfs.Mount(backing, writable)
|
||||
}
|
||||
|
||||
// dataPartitionWriteHandle keeps the raw partition marked busy until the
|
||||
// write that opened it has finished and closed the handle.
|
||||
type dataPartitionWriteHandle struct {
|
||||
diskfs.Image
|
||||
released atomic.Bool
|
||||
}
|
||||
|
||||
func (h *dataPartitionWriteHandle) Close() error {
|
||||
err := h.Image.Close()
|
||||
if h.released.CompareAndSwap(false, true) {
|
||||
partitionWriteRefs.Add(-1)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// BeginDataPartitionWrite reserves the data partition for a transfer that does
|
||||
// not go through a medium handle, such as writing a whole image download. The
|
||||
// reference is taken while the state is checked, then held until release runs;
|
||||
// handing the raw partition to the target machine is refused in between.
|
||||
func BeginDataPartitionWrite() (func(), error) {
|
||||
mediumOperationMu.Lock()
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if status.State == diskStatePartition {
|
||||
mediumOperationMu.Unlock()
|
||||
return nil, ErrDataPartitionInUse
|
||||
}
|
||||
partitionWriteRefs.Add(1)
|
||||
mediumOperationMu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
partitionWriteRefs.Add(-1)
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// releaseDataPartition hands /data back to this side: it is read-only while the
|
||||
// emulated disk serves the raw partition, and writable again once no medium or
|
||||
// a read-only image is attached.
|
||||
func releaseDataPartition() {
|
||||
// SetDataWritable drops the target-owned cache before remounting read-write.
|
||||
if err := SetDataWritable(true); err != nil {
|
||||
log.Warnf("make %s writable failed: %s", mediaDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentHeader builds the Content-Disposition of a download the way
|
||||
// browsers want it: an ASCII fallback for everything old, plus the real name
|
||||
// percent-encoded, because a file name is not necessarily ASCII and %q would
|
||||
// send a Go-quoted string instead of a name.
|
||||
func attachmentHeader(name string) string {
|
||||
fallback := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
|
||||
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", fallback, rfc5987Encode(name))
|
||||
}
|
||||
|
||||
// rfc5987Encode percent-encodes every byte that is not an RFC 5987 attr-char.
|
||||
// url.PathEscape is not that set: it leaves "=", ":", "@" and friends alone,
|
||||
// which a strict parser reads as the end of the value.
|
||||
func rfc5987Encode(value string) string {
|
||||
const hex = "0123456789ABCDEF"
|
||||
|
||||
var encoded strings.Builder
|
||||
for i := 0; i < len(value); i++ {
|
||||
b := value[i]
|
||||
if isAttrChar(b) {
|
||||
encoded.WriteByte(b)
|
||||
continue
|
||||
}
|
||||
encoded.WriteByte('%')
|
||||
encoded.WriteByte(hex[b>>4])
|
||||
encoded.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
return encoded.String()
|
||||
}
|
||||
|
||||
func isAttrChar(b byte) bool {
|
||||
switch {
|
||||
case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':
|
||||
return true
|
||||
}
|
||||
return strings.IndexByte("!#$&+-.^_`|~", b) >= 0
|
||||
}
|
||||
|
||||
// ErrDataPartitionInUse reports a write that would land on the data partition
|
||||
// while the target machine owns it.
|
||||
var ErrDataPartitionInUse = errors.New("the data partition is attached to the target machine; eject it first")
|
||||
|
||||
// EnsureDataPartitionWritable performs the one-shot form of the partition
|
||||
// ownership check. Anything that keeps writing after the check has to hold
|
||||
// BeginDataPartitionWrite instead, or the partition can be mounted in between.
|
||||
func EnsureDataPartitionWritable() error {
|
||||
release, err := BeginDataPartitionWrite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release()
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteGuard decides whether removing target, the canonical path of a file
|
||||
// or directory, would take away a medium that must not vanish: the one the
|
||||
// target machine is served (backing) or one the file manager keeps mounted. A
|
||||
// directory delete is recursive, so a medium anywhere below target counts.
|
||||
func deleteGuard(target, backing string, mounted []string) error {
|
||||
if backing != "" && pathWithin(backing, target) {
|
||||
return errMediumAttached
|
||||
}
|
||||
for _, path := range mounted {
|
||||
if pathWithin(path, target) {
|
||||
return errMediumBusy
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathWithin reports whether path is dir or lies below it. Both sides are
|
||||
// compared upper-cased, the way exFAT matches names, so a spelling that only
|
||||
// differs in case cannot slip past a check that the canonical lookup missed.
|
||||
func pathWithin(path, dir string) bool {
|
||||
path = strings.ToUpper(path)
|
||||
dir = strings.ToUpper(strings.TrimSuffix(dir, "/"))
|
||||
return path == dir || strings.HasPrefix(path, dir+"/")
|
||||
}
|
||||
|
||||
// IsAttached reports whether the emulated disk is currently backed by path.
|
||||
// Callers that replace a file in place have to ask first: the gadget keeps
|
||||
// using the inode it opened, so renaming over it leaves the target machine
|
||||
// reading a file the rest of the system can no longer see.
|
||||
func IsAttached(path string) bool {
|
||||
if canonical, err := utils.CanonicalMediaPath(path); err == nil {
|
||||
path = canonical
|
||||
}
|
||||
|
||||
status, err := readDiskStatus()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return status.Backing != "" && status.Backing == path
|
||||
}
|
||||
|
||||
func respondError(c *gin.Context, err error) {
|
||||
var rsp proto.Response
|
||||
|
||||
switch {
|
||||
case errors.Is(err, utils.ErrInvalidMediaID), errors.Is(err, utils.ErrInvalidMediaPath):
|
||||
rsp.ErrRsp(c, codeInvalidArgument, err.Error())
|
||||
case errors.Is(err, errStorageDisabled):
|
||||
rsp.ErrRsp(c, codeStorageDisabled, err.Error())
|
||||
case errors.Is(err, ErrMediumLocked):
|
||||
rsp.ErrRsp(c, codeMediumLocked, err.Error())
|
||||
case errors.Is(err, errPartitionOwned), errors.Is(err, errMediumAttached),
|
||||
errors.Is(err, errMediumBusy), errors.Is(err, diskfs.ErrBusy):
|
||||
rsp.ErrRsp(c, codeMediumBusy, err.Error())
|
||||
case errors.Is(err, diskfs.ErrUnsupported), errors.Is(err, errNotAMedium):
|
||||
rsp.ErrRsp(c, codeNotBrowsable, err.Error())
|
||||
case errors.Is(err, errNoSpace), errors.Is(err, errTooManyJobs):
|
||||
rsp.ErrRsp(c, codeInsufficientSpace, err.Error())
|
||||
case errors.Is(err, errNotOptical):
|
||||
rsp.ErrRsp(c, codeNotOptical, err.Error())
|
||||
default:
|
||||
rsp.ErrRsp(c, codeOperationFailed, err.Error())
|
||||
}
|
||||
}
|
||||
187
server/service/storage/media_test.go
Normal file
187
server/service/storage/media_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeleteGuard(t *testing.T) {
|
||||
const attached = "/data/images/win.iso"
|
||||
mounted := []string{"/data/share/disk.img"}
|
||||
|
||||
tests := []struct {
|
||||
target string
|
||||
want error
|
||||
}{
|
||||
{"/data/images/win.iso", errMediumAttached},
|
||||
{"/data/IMAGES/WIN.ISO", errMediumAttached},
|
||||
{"/data/images", errMediumAttached},
|
||||
{"/data/IMAGES", errMediumAttached},
|
||||
{"/data/Images/", errMediumAttached},
|
||||
{"/data/share/disk.img", errMediumBusy},
|
||||
{"/data/Share", errMediumBusy},
|
||||
{"/data/images/win.iso.bak", nil},
|
||||
{"/data/imagesold", nil},
|
||||
{"/data/share/other.img", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := deleteGuard(tt.target, attached, mounted); !errors.Is(got, tt.want) || (got == nil) != (tt.want == nil) {
|
||||
t.Errorf("deleteGuard(%q) = %v, want %v", tt.target, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := deleteGuard("/data/images", "", nil); err != nil {
|
||||
t.Errorf("deleteGuard with nothing attached = %v, want nil", err)
|
||||
}
|
||||
// The raw partition is served by device path, which no file under /data
|
||||
// can match.
|
||||
if err := deleteGuard("/data/images", partitionDevice, nil); err != nil {
|
||||
t.Errorf("deleteGuard with the partition attached = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathWithinRelative(t *testing.T) {
|
||||
// The running upload's target is medium relative, like a delete request.
|
||||
if !pathWithin("dir/sub/file.bin", "dir") || !pathWithin("DIR/file.bin", "dir") {
|
||||
t.Error("an upload below the directory was not matched")
|
||||
}
|
||||
if pathWithin("dir2/file.bin", "dir") {
|
||||
t.Error("a sibling with a common prefix was matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonWaiterHandsTurnOn(t *testing.T) {
|
||||
s := &Service{jobState: jobStateIdle}
|
||||
|
||||
done, err := s.beginJob(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := make(chan struct{})
|
||||
second := make(chan struct{})
|
||||
s.jobWaiters = []chan struct{}{first, second}
|
||||
|
||||
// The slot goes to the first waiter, whose client is already gone.
|
||||
s.finishJob(done, jobStateSuccess, "")
|
||||
assertClosed(t, first, "first waiter")
|
||||
assertOpen(t, second, "second waiter before the abandon")
|
||||
|
||||
s.abandonWaiter(first)
|
||||
assertClosed(t, second, "second waiter")
|
||||
if len(s.jobWaiters) != 0 {
|
||||
t.Errorf("queue holds %d waiters, want 0", len(s.jobWaiters))
|
||||
}
|
||||
if activeJobs.Load() != 0 {
|
||||
t.Errorf("activeJobs = %d, want 0", activeJobs.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonQueuedWaiterLeavesOthers(t *testing.T) {
|
||||
s := &Service{jobState: jobStateIdle}
|
||||
|
||||
done, err := s.beginJob(context.Background(), "a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.finishJob(done, jobStateSuccess, "")
|
||||
|
||||
first := make(chan struct{})
|
||||
second := make(chan struct{})
|
||||
s.jobWaiters = []chan struct{}{first, second}
|
||||
|
||||
s.abandonWaiter(first)
|
||||
assertOpen(t, second, "second waiter")
|
||||
if len(s.jobWaiters) != 1 || s.jobWaiters[0] != second {
|
||||
t.Errorf("queue = %v, want only the second waiter", s.jobWaiters)
|
||||
}
|
||||
}
|
||||
|
||||
func assertClosed(t *testing.T, ch chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
t.Errorf("%s was not woken", what)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOpen(t *testing.T, ch chan struct{}, what string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
t.Errorf("%s was woken", what)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropCachesValues(t *testing.T) {
|
||||
// The 5.10 sysctl refuses anything outside 1..4.
|
||||
for _, value := range []string{dropCachesQuiet, dropCachesDentry, dropCachesAllData} {
|
||||
if len(value) != 1 || value[0] < '1' || value[0] > '4' {
|
||||
t.Errorf("drop_caches value %q is outside 1..4", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRFC5987Encode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"ubuntu-24.04.iso": "ubuntu-24.04.iso",
|
||||
"a b=c:d@e;f,g'h": "a%20b%3Dc%3Ad%40e%3Bf%2Cg%27h",
|
||||
"!#$&+-.^_`|~": "!#$&+-.^_`|~",
|
||||
"中.txt": "%E4%B8%AD.txt",
|
||||
"100%\"x\\y/z": "100%25%22x%5Cy%2Fz",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := rfc5987Encode(in); got != want {
|
||||
t.Errorf("rfc5987Encode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
header := attachmentHeader(`中 "a".iso`)
|
||||
if !strings.HasPrefix(header, `attachment; filename="_ _a_.iso"; filename*=UTF-8''`) {
|
||||
t.Errorf("attachmentHeader = %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldsMedia(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite := func(rel string) {
|
||||
full := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mustWrite("images/win.ISO")
|
||||
mustWrite("share/notes.txt")
|
||||
mustWrite("share/deep/disk.img")
|
||||
mustWrite("docs/readme.txt")
|
||||
|
||||
cases := map[string]bool{
|
||||
"images": true,
|
||||
"images/win.ISO": true,
|
||||
"share": true,
|
||||
"share/notes.txt": false,
|
||||
"docs": false,
|
||||
"missing/new.iso": true,
|
||||
"missing/notes.txt": false,
|
||||
"share/deep/disk.img": true,
|
||||
}
|
||||
for rel, want := range cases {
|
||||
got, err := holdsMedia(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
t.Fatalf("holdsMedia(%s): %v", rel, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("holdsMedia(%s) = %v, want %v", rel, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
package storage
|
||||
|
||||
type Service struct{}
|
||||
import "sync"
|
||||
|
||||
// Service backs the virtual media API. The job fields belong to the single
|
||||
// long running media operation (a file being written into a medium); they
|
||||
// follow the download service's shape, so the web UI polls both with the same
|
||||
// vocabulary.
|
||||
type Service struct {
|
||||
jobMutex sync.Mutex
|
||||
jobDone chan struct{}
|
||||
jobWaiters []chan struct{}
|
||||
jobState jobState
|
||||
jobName string
|
||||
jobLoaded int64
|
||||
jobTotal int64
|
||||
jobMessage string
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
startPartitionWatcher()
|
||||
return &Service{jobState: jobStateIdle}
|
||||
}
|
||||
|
||||
@@ -69,36 +69,92 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
|
||||
var device string
|
||||
var commands []string
|
||||
preparePartition := false
|
||||
|
||||
// A caller that says which state it wants gets that state; a caller that
|
||||
// does not is toggling, the way this endpoint has always worked.
|
||||
switch req.Device {
|
||||
case "network":
|
||||
device = virtualNetwork
|
||||
|
||||
exist, _ := isDeviceExist(device)
|
||||
if !exist {
|
||||
enable := !exist
|
||||
if req.Enable != nil {
|
||||
enable = *req.Enable
|
||||
}
|
||||
if enable && !exist {
|
||||
commands = mountNetworkCommands
|
||||
} else {
|
||||
} else if !enable && exist {
|
||||
commands = unmountNetworkCommands
|
||||
}
|
||||
if len(commands) > 0 {
|
||||
// Restarting the gadget for a network change also makes the boot
|
||||
// script attach whatever /boot/usb.disk0 names. When the virtual
|
||||
// disk is enabled that is the raw data partition by default, so
|
||||
// this transition needs the same protection as a disk mount.
|
||||
diskEnabled, err := isDeviceExist(virtualDisk)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "check virtual disk failed")
|
||||
return
|
||||
}
|
||||
preparePartition = diskEnabled
|
||||
}
|
||||
case "disk":
|
||||
device = virtualDisk
|
||||
|
||||
if diskAttached() {
|
||||
commands = unmountDiskCommands
|
||||
} else {
|
||||
// the host writes the partition from now on. A mounted image is released by
|
||||
// the restarted gadget, which attaches the raw partition again.
|
||||
// The settings switch toggles: it reads "on" only while the raw
|
||||
// partition is served, so turning it on while an image is mounted
|
||||
// cancels that mount, which the UI confirms first. An explicit enable
|
||||
// is a request for the virtual disk to exist at all, so it compares
|
||||
// against that instead and leaves a served image alone.
|
||||
enabled, err := isDeviceExist(device)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "check virtual disk failed")
|
||||
return
|
||||
}
|
||||
current := diskAttached()
|
||||
enable := !current
|
||||
if req.Enable != nil {
|
||||
current = enabled
|
||||
enable = *req.Enable
|
||||
}
|
||||
// Asking for the state that already holds changes nothing: a caller
|
||||
// that says "enable" must not switch off a running share.
|
||||
if enable != current {
|
||||
if current {
|
||||
commands = unmountDiskCommands
|
||||
} else {
|
||||
// The restarted gadget attaches the raw partition. The actual
|
||||
// read-only transition happens under the media operation lock
|
||||
// below, before any script can expose the partition.
|
||||
preparePartition = true
|
||||
commands = mountDiskCommands
|
||||
}
|
||||
}
|
||||
default:
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if len(commands) > 0 && (device == virtualDisk || preparePartition) {
|
||||
unlock, err := storage.LockVirtualDiskTransition()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, err.Error())
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
if preparePartition {
|
||||
// The host writes the partition from now on. A mounted image is
|
||||
// released by the restarted gadget, which attaches the raw
|
||||
// partition again. This must happen before the script can expose
|
||||
// that partition, including when the script is changing networking.
|
||||
if err := storage.SetDataWritable(false); err != nil {
|
||||
log.Errorf("make /data read-only failed: %s", err)
|
||||
rsp.ErrRsp(c, -3, "make /data read-only failed")
|
||||
return
|
||||
}
|
||||
|
||||
commands = mountDiskCommands
|
||||
}
|
||||
default:
|
||||
rsp.ErrRsp(c, -2, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
h := hid.GetHid()
|
||||
@@ -109,21 +165,30 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) {
|
||||
h.Unlock()
|
||||
}()
|
||||
|
||||
var commandErr error
|
||||
for _, command := range commands {
|
||||
err := exec.Command("sh", "-c", command).Run()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "operation failed")
|
||||
return
|
||||
if commandErr = exec.Command("sh", "-c", command).Run(); commandErr != nil {
|
||||
log.Errorf("run %q failed: %s", command, commandErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// the disk is gone from the host, the KVM writes /data itself again
|
||||
if device == virtualDisk && !diskAttached() {
|
||||
// Whatever the scripts got to, /data belongs to this side again unless
|
||||
// the raw partition really is served to the target machine now. That
|
||||
// covers a failed script, which may have stopped before re-attaching the
|
||||
// partition it was made read-only for, and a network change, after which
|
||||
// the restarted gadget may serve the partition or not.
|
||||
if len(commands) > 0 && (device == virtualDisk || preparePartition) && !diskAttached() {
|
||||
if err := storage.SetDataWritable(true); err != nil {
|
||||
log.Errorf("make /data writable failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if commandErr != nil {
|
||||
rsp.ErrRsp(c, -3, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
on, _ := isDeviceExist(device)
|
||||
rsp.OkRspWithData(c, &proto.UpdateVirtualDeviceRsp{
|
||||
On: on,
|
||||
|
||||
@@ -27,9 +27,11 @@ const (
|
||||
clientHeartbeatTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
func NewClient(ws *websocket.Conn) *Client {
|
||||
func NewClient(ws *websocket.Conn, readOnly bool, username string) *Client {
|
||||
client := &Client{
|
||||
ws: ws,
|
||||
readOnly: readOnly,
|
||||
username: username,
|
||||
hid: hid.GetHid(),
|
||||
manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()),
|
||||
keyboard: make(chan hid.QueuedReport, 200),
|
||||
@@ -85,6 +87,9 @@ func (c *Client) Read() error {
|
||||
switch data[0] {
|
||||
case Heartbeat:
|
||||
case KeyboardEvent:
|
||||
if c.readOnly {
|
||||
continue
|
||||
}
|
||||
report := data[1:]
|
||||
if len(report) != 8 {
|
||||
log.Debugf("invalid manual keyboard report: %v", report)
|
||||
@@ -92,6 +97,9 @@ func (c *Client) Read() error {
|
||||
}
|
||||
c.queueManualReport(c.keyboard, inputcontrol.ManualKeyboard, report, keyboardReportHeld(report), true)
|
||||
case MouseEvent:
|
||||
if c.readOnly {
|
||||
continue
|
||||
}
|
||||
report := data[1:]
|
||||
if len(report) != 4 && len(report) != 6 {
|
||||
log.Debugf("invalid manual mouse report: %v", report)
|
||||
|
||||
@@ -33,6 +33,16 @@ func (m *Manager) RemoveClient(ws *websocket.Conn) {
|
||||
delete(m.clients, ws)
|
||||
}
|
||||
|
||||
// NotifyUser sends an event to every connection owned by the user, e.g. to
|
||||
// tell the frontend that account permissions changed.
|
||||
func (m *Manager) NotifyUser(username string, event string, data string) {
|
||||
for _, client := range m.GetClients() {
|
||||
if client.username == username {
|
||||
_ = client.Write(event, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetClients() []*Client {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/authn"
|
||||
"NanoKVM-Server/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -31,7 +32,16 @@ func (s *Service) Connect(c *gin.Context) {
|
||||
|
||||
log.Debug("websocket connected")
|
||||
|
||||
client := NewClient(ws)
|
||||
// View-only sessions keep receiving frames but their keyboard and mouse
|
||||
// reports are dropped.
|
||||
readOnly := true
|
||||
username := ""
|
||||
if principal, ok := middleware.CurrentPrincipal(c); ok {
|
||||
readOnly = !principal.Has(authn.PermConsoleControl)
|
||||
username = principal.Username
|
||||
}
|
||||
|
||||
client := NewClient(ws, readOnly, username)
|
||||
|
||||
manager := GetManager()
|
||||
manager.AddClient(ws, client)
|
||||
|
||||
47
server/service/ws/storage.go
Normal file
47
server/service/ws/storage.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/storage"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// StorageEvent tells the web UI that the shared files view was refreshed: the
|
||||
// watcher drops the exFAT metadata caches and reports what the target machine
|
||||
// wrote to the data partition, including the size a transfer has reached. The
|
||||
// listener simply re-reads what it is showing.
|
||||
const StorageEvent = "storage"
|
||||
|
||||
// storageNotifications carries the refresh notifications to their own
|
||||
// goroutine. The watcher refreshes once a second while the target machine owns
|
||||
// the shared files, and a client that stopped reading would otherwise hold it
|
||||
// up for a whole write deadline on every refresh. One slot is enough: a
|
||||
// notification that arrives while one is still going out is not worth a second
|
||||
// write, because the listener reads the current state either way.
|
||||
var storageNotifications = make(chan struct{}, 1)
|
||||
|
||||
func init() {
|
||||
storage.SubscribePartitionChanges(queueStorageChanged)
|
||||
go broadcastStorageChanges()
|
||||
}
|
||||
|
||||
func queueStorageChanged() {
|
||||
select {
|
||||
case storageNotifications <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastStorageChanges() {
|
||||
for range storageNotifications {
|
||||
broadcastStorageChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastStorageChanged() {
|
||||
for _, client := range GetManager().GetClients() {
|
||||
if err := client.Write(StorageEvent, "{}"); err != nil {
|
||||
log.Errorf("failed to send storage change: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type Manager struct {
|
||||
|
||||
type Client struct {
|
||||
ws *websocket.Conn
|
||||
readOnly bool
|
||||
username string
|
||||
hid *hid.Hid
|
||||
manual *inputcontrol.ManualSession
|
||||
keyboard chan hid.QueuedReport
|
||||
|
||||
233
server/utils/mediapath.go
Normal file
233
server/utils/mediapath.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MediaDirectory is the volume the emulated USB disk exposes: the target
|
||||
// machine sees it as one removable drive, so nothing secret belongs here.
|
||||
//
|
||||
// It is split into two folders so both sides know what they are looking at:
|
||||
// the shared folder is the drop box for exchanging files, the images folder is
|
||||
// this device's own library of disk images.
|
||||
const (
|
||||
MediaDirectory = "/data"
|
||||
ShareDirectory = MediaDirectory + "/share"
|
||||
ImagesDirectory = MediaDirectory + "/images"
|
||||
)
|
||||
|
||||
// maxNameLength is the FAT/exFAT limit in UTF-16 code units; counting runes is
|
||||
// the closest practical approximation on the Go side.
|
||||
const maxNameLength = 255
|
||||
|
||||
// forbiddenNameChars cannot appear in a name on any platform that mounts
|
||||
// FAT/exFAT, so the file manager rejects them before the kernel sees them.
|
||||
const forbiddenNameChars = `<>:"/\|?*`
|
||||
|
||||
// reservedNames are the DOS device names Windows still refuses, with or
|
||||
// without an extension.
|
||||
var reservedNames = map[string]bool{
|
||||
"CON": true, "PRN": true, "AUX": true, "NUL": true,
|
||||
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
|
||||
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
|
||||
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
|
||||
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidMediaID = errors.New("invalid medium id")
|
||||
ErrInvalidMediaPath = errors.New("invalid media path")
|
||||
)
|
||||
|
||||
// IsMediaFile reports whether name looks like a virtual medium. The emulated
|
||||
// disk only ever exposes disk images, so the file manager refuses to touch
|
||||
// anything else in MediaDirectory.
|
||||
func IsMediaFile(name string) bool {
|
||||
return IsISOFile(name) || strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".img")
|
||||
}
|
||||
|
||||
// IsISOFile reports whether name is an ISO image, which is the one medium
|
||||
// whose contents can be checked before it is mounted.
|
||||
func IsISOFile(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".iso")
|
||||
}
|
||||
|
||||
// ResolveMediaID turns a medium id, a path relative to MediaDirectory, into an
|
||||
// absolute path. Every request naming a file on the device goes through here,
|
||||
// so escapes (absolute paths, "..", separators where they do not belong) are
|
||||
// rejected rather than patched up at the call site.
|
||||
func ResolveMediaID(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || strings.ContainsRune(id, 0) {
|
||||
return "", fmt.Errorf("%w: empty", ErrInvalidMediaID)
|
||||
}
|
||||
if strings.HasPrefix(id, "/") || strings.HasPrefix(id, `\`) {
|
||||
return "", fmt.Errorf("%w: must be relative to %s", ErrInvalidMediaID, MediaDirectory)
|
||||
}
|
||||
|
||||
rel := filepath.FromSlash(id)
|
||||
if !filepath.IsLocal(rel) {
|
||||
return "", fmt.Errorf("%w: %s escapes %s", ErrInvalidMediaID, id, MediaDirectory)
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
|
||||
if err := validateName(segment); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidMediaID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsMediaFile(rel) {
|
||||
return "", fmt.Errorf("%w: %s is not a disk image", ErrInvalidMediaID, id)
|
||||
}
|
||||
|
||||
return canonicalMediaPath(MediaDirectory, rel)
|
||||
}
|
||||
|
||||
// CanonicalMediaPath resolves an absolute medium path to the spelling used by
|
||||
// the directory entries on disk. Like ResolveMediaID, it rejects paths outside
|
||||
// MediaDirectory and names that are not disk images.
|
||||
func CanonicalMediaPath(name string) (string, error) {
|
||||
rel, err := filepath.Rel(MediaDirectory, filepath.Clean(name))
|
||||
if err != nil || !filepath.IsLocal(rel) {
|
||||
return "", fmt.Errorf("%w: %s is outside %s", ErrInvalidMediaID, name, MediaDirectory)
|
||||
}
|
||||
|
||||
return ResolveMediaID(filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
// CanonicalPathInside resolves rel, a path already checked by
|
||||
// ValidateMediumPath, to the spelling the directory entries under root use.
|
||||
// Unlike CanonicalMediaPath it accepts directories and arbitrary files: a
|
||||
// delete has to compare what it removes against the canonical paths of the
|
||||
// attached and mounted media, and "IMAGES" names the same exFAT directory as
|
||||
// "images".
|
||||
func CanonicalPathInside(root, rel string) (string, error) {
|
||||
if rel == "" || rel == "/" {
|
||||
return root, nil
|
||||
}
|
||||
return canonicalMediaPath(root, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
// canonicalMediaPath resolves every existing component to its on-disk name.
|
||||
// exFAT lookups are case-insensitive, while Go string comparisons are not: an
|
||||
// all-lower-case request and an upper-case request can name the same medium.
|
||||
// Prefer an exact match so this also remains deterministic on a case-sensitive
|
||||
// filesystem, and reject ambiguous folds instead of choosing between files.
|
||||
// A missing component is kept as requested so callers can still create a new
|
||||
// image or report the original not-found error.
|
||||
func canonicalMediaPath(root, rel string) (string, error) {
|
||||
segments := strings.Split(filepath.ToSlash(rel), "/")
|
||||
current := root
|
||||
|
||||
for i, segment := range segments {
|
||||
entries, err := os.ReadDir(current)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
parts := append([]string{current}, segments[i:]...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
return "", fmt.Errorf("resolve medium path %s: %w", filepath.Join(root, rel), err)
|
||||
}
|
||||
|
||||
matches := make([]string, 0, 1)
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == segment {
|
||||
matches = []string{segment}
|
||||
break
|
||||
}
|
||||
if strings.EqualFold(entry.Name(), segment) {
|
||||
matches = append(matches, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) > 1 {
|
||||
return "", fmt.Errorf("%w: %s has more than one case-insensitive match", ErrInvalidMediaID, filepath.Join(root, rel))
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
parts := append([]string{current}, segments[i:]...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
|
||||
current = filepath.Join(current, matches[0])
|
||||
}
|
||||
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// ValidateMediumPath normalizes a path inside a mounted medium and returns it
|
||||
// slash separated and relative to the medium root ("/" means the root itself).
|
||||
// It takes the same shape the web UI shows, so a leading slash is accepted and
|
||||
// stripped.
|
||||
func ValidateMediumPath(p string) (string, error) {
|
||||
if strings.ContainsRune(p, 0) {
|
||||
return "", fmt.Errorf("%w: contains NUL", ErrInvalidMediaPath)
|
||||
}
|
||||
|
||||
p = strings.TrimPrefix(strings.TrimSpace(p), "/")
|
||||
if p == "" {
|
||||
return "/", nil
|
||||
}
|
||||
|
||||
clean := path.Clean(p)
|
||||
if clean == "." {
|
||||
return "/", nil
|
||||
}
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("%w: %s escapes the medium", ErrInvalidMediaPath, p)
|
||||
}
|
||||
|
||||
for _, segment := range strings.Split(clean, "/") {
|
||||
if err := validateName(segment); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrInvalidMediaPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// ValidateFileName checks a single file name for FAT/exFAT compatibility. It
|
||||
// is what an upload's browser-supplied name has to pass before it is written
|
||||
// into a medium.
|
||||
func ValidateFileName(name string) error {
|
||||
return validateName(name)
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New("empty name")
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return fmt.Errorf("reserved name %q", name)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxNameLength {
|
||||
return fmt.Errorf("name longer than %d characters", maxNameLength)
|
||||
}
|
||||
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
||||
return fmt.Errorf("name %q ends with a dot or space", name)
|
||||
}
|
||||
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return fmt.Errorf("name %q contains a control character", name)
|
||||
}
|
||||
if strings.ContainsRune(forbiddenNameChars, r) {
|
||||
return fmt.Errorf("name %q contains %q", name, r)
|
||||
}
|
||||
}
|
||||
|
||||
base := name
|
||||
if i := strings.IndexByte(base, '.'); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
if reservedNames[strings.ToUpper(base)] {
|
||||
return fmt.Errorf("name %q is reserved on Windows", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
78
server/utils/mediapath_test.go
Normal file
78
server/utils/mediapath_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalPathInside(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "Images", "Sub"))
|
||||
mustWrite(t, filepath.Join(root, "Images", "Win.iso"))
|
||||
mustWrite(t, filepath.Join(root, "Images", "notes.txt"))
|
||||
|
||||
tests := []struct {
|
||||
rel string
|
||||
want string
|
||||
}{
|
||||
{"/", root},
|
||||
{"images", filepath.Join(root, "Images")},
|
||||
{"IMAGES", filepath.Join(root, "Images")},
|
||||
{"images/sub", filepath.Join(root, "Images", "Sub")},
|
||||
{"images/WIN.ISO", filepath.Join(root, "Images", "Win.iso")},
|
||||
{"IMAGES/NOTES.TXT", filepath.Join(root, "Images", "notes.txt")},
|
||||
// A missing component keeps the requested spelling from there on.
|
||||
{"images/new/File.bin", filepath.Join(root, "Images", "new", "File.bin")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := CanonicalPathInside(root, tt.rel)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalPathInside(%q) error: %v", tt.rel, err)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("CanonicalPathInside(%q) = %q, want %q", tt.rel, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalPathInsideAmbiguous(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdir(t, filepath.Join(root, "a"))
|
||||
mustMkdir(t, filepath.Join(root, "A"))
|
||||
if _, err := os.Stat(filepath.Join(root, "a")); err != nil {
|
||||
t.Skip("case-insensitive filesystem")
|
||||
}
|
||||
entries, _ := os.ReadDir(root)
|
||||
if len(entries) != 2 {
|
||||
t.Skip("case-insensitive filesystem")
|
||||
}
|
||||
|
||||
// An exact match wins over a fold.
|
||||
if got, err := CanonicalPathInside(root, "a"); err != nil || got != filepath.Join(root, "a") {
|
||||
t.Errorf("exact match = %q, %v", got, err)
|
||||
}
|
||||
// Two folds and no exact match is refused instead of guessed.
|
||||
mustMkdir(t, filepath.Join(root, "Bb"))
|
||||
mustMkdir(t, filepath.Join(root, "bB"))
|
||||
if _, err := CanonicalPathInside(root, "bb"); !errors.Is(err, ErrInvalidMediaID) {
|
||||
t.Errorf("ambiguous fold error = %v, want ErrInvalidMediaID", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, file string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
91
server/utils/progress.go
Normal file
91
server/utils/progress.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// progressInterval matches the polling cadence of the web UI, so a transfer
|
||||
// never reports a percentage the client has already stopped asking for.
|
||||
const progressInterval = 2500 * time.Millisecond
|
||||
|
||||
// ProgressWriter counts the bytes written through it and reports progress to
|
||||
// onProgress on a fixed ticker. Transfers that take minutes (image downloads
|
||||
// and uploads, media creation) use it to feed the status endpoints the web UI
|
||||
// polls.
|
||||
type ProgressWriter struct {
|
||||
writer io.Writer
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
onProgress func(loaded, total int64)
|
||||
}
|
||||
|
||||
// NewProgressWriter wraps writer. totalSize is the expected number of bytes;
|
||||
// when it is unknown (a chunked response, for example) no progress is
|
||||
// reported. onProgress receives the bytes written so far and the total.
|
||||
func NewProgressWriter(writer io.Writer, totalSize int64, onProgress func(loaded, total int64)) *ProgressWriter {
|
||||
pw := &ProgressWriter{
|
||||
writer: writer,
|
||||
totalSize: totalSize,
|
||||
onProgress: onProgress,
|
||||
}
|
||||
pw.startTicker()
|
||||
return pw
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) startTicker() {
|
||||
pw.ticker = time.NewTicker(progressInterval)
|
||||
pw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-pw.done:
|
||||
return
|
||||
case <-pw.ticker.C:
|
||||
pw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop halts the ticker. It is safe to call more than once and on a nil
|
||||
// writer, so callers can defer it unconditionally.
|
||||
func (pw *ProgressWriter) Stop() {
|
||||
if pw == nil {
|
||||
return
|
||||
}
|
||||
pw.stopOnce.Do(func() {
|
||||
pw.ticker.Stop()
|
||||
close(pw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) updateProgress() {
|
||||
if pw.totalSize <= 0 || pw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pw.onProgress(pw.total.Load(), pw.totalSize)
|
||||
}
|
||||
|
||||
// FormatPercentage renders transfer progress the way the status endpoints
|
||||
// report it: values like "42.13%", and an empty string before the first tick
|
||||
// or when the total size is unknown.
|
||||
func FormatPercentage(loaded, total int64) string {
|
||||
if loaded <= 0 || total <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.2f%%", float64(loaded)/float64(total)*100)
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
25
server/utils/space.go
Normal file
25
server/utils/space.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Statfs reports the total and available bytes of the filesystem holding path.
|
||||
// It applies no reserve policy on purpose: each caller decides what it has to
|
||||
// keep back, so the numbers stay plain.
|
||||
func Statfs(path string) (total, available uint64, err error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, fmt.Errorf("stat filesystem %s: %w", path, err)
|
||||
}
|
||||
|
||||
blockSize := uint64(stat.Bsize)
|
||||
if blockSize != 0 && (stat.Blocks > math.MaxUint64/blockSize || stat.Bavail > math.MaxUint64/blockSize) {
|
||||
return 0, 0, fmt.Errorf("filesystem size overflow on %s", path)
|
||||
}
|
||||
|
||||
return stat.Blocks * blockSize, stat.Bavail * blockSize, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
// TransferSentinelPath coordinates application uploads and virtual-media
|
||||
// transfers. /run is already required by the device init scripts and preserves
|
||||
// TransferSentinelPath coordinates transfers that write new files into the
|
||||
// data partition: application updates and image downloads. Writing into an
|
||||
// image a medium already owns does not take it. /run is already required by the device init scripts and preserves
|
||||
// the previous reboot-clears-stale-state behaviour without using /tmp.
|
||||
const TransferSentinelPath = "/run/nanokvm-transfer-in-progress"
|
||||
|
||||
@@ -15,6 +15,7 @@ src
|
||||
├── pages // web pages
|
||||
│ ├── auth // login and password
|
||||
│ ├── desktop // remote desktop
|
||||
│ ├── storage // virtual media and the files inside them
|
||||
│ └── terminal // web terminal
|
||||
├── router.tsx // routers
|
||||
└── types // types
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
53
web/pnpm-lock.yaml
generated
53
web/pnpm-lock.yaml
generated
@@ -11,9 +11,6 @@ importers:
|
||||
'@ant-design/icons':
|
||||
specifier: ^5.6.1
|
||||
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@xterm/addon-attach':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||
@@ -650,9 +647,6 @@ packages:
|
||||
'@oxc-project/types@0.142.0':
|
||||
resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
@@ -687,15 +681,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
@@ -779,19 +764,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10':
|
||||
resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -3947,8 +3919,6 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.142.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)':
|
||||
@@ -3985,12 +3955,6 @@ snapshots:
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -4057,23 +4021,6 @@ snapshots:
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
|
||||
@@ -2,16 +2,52 @@ import { http } from '@/lib/http';
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
|
||||
export type Permission =
|
||||
| 'console.view'
|
||||
| 'console.control'
|
||||
| 'power'
|
||||
| 'medium.attach'
|
||||
| 'library.read'
|
||||
| 'library.write'
|
||||
| 'terminal'
|
||||
| 'device'
|
||||
| 'network'
|
||||
| 'update'
|
||||
| 'mcp'
|
||||
| 'tailscale'
|
||||
| 'ai';
|
||||
|
||||
// Group names the party a grant reaches: the console this account watches, the
|
||||
// machine behind it, the media library on this device, the device itself, or
|
||||
// the services that reach it on their own.
|
||||
export type PermissionGroup = 'console' | 'target' | 'library' | 'host' | 'services';
|
||||
|
||||
// Risk states how far a grant reaches. There are two levels: a plain grant, and
|
||||
// the grants that amount to full device control, which the editor marks and
|
||||
// names before the grants are saved.
|
||||
export type PermissionRisk = 'info' | 'warn';
|
||||
|
||||
// PermissionDescriptor is one catalog entry as the server serves it. The editor
|
||||
// renders the catalog instead of keeping its own copy of the model, so labels,
|
||||
// groups and implied grants cannot drift from what the server enforces.
|
||||
export type PermissionDescriptor = {
|
||||
id: Permission;
|
||||
group: PermissionGroup;
|
||||
risk: PermissionRisk;
|
||||
implies?: Permission[];
|
||||
};
|
||||
|
||||
export type Account = {
|
||||
username: string;
|
||||
role: UserRole;
|
||||
// The effective grants: the stored set plus everything it implies.
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export type User = Account & {
|
||||
enabled: boolean;
|
||||
systemAccount?: boolean;
|
||||
};
|
||||
|
||||
export function login(username: string, password: string) {
|
||||
const data = {
|
||||
username,
|
||||
@@ -40,11 +76,25 @@ export function getUsers() {
|
||||
return http.get('/api/auth/users');
|
||||
}
|
||||
|
||||
export function createUser(username: string, password: string, role: UserRole) {
|
||||
return http.post('/api/auth/users', { username, password, role });
|
||||
export function getPermissions() {
|
||||
return http.get('/api/auth/permissions');
|
||||
}
|
||||
|
||||
export function updateUser(username: string, data: Partial<Pick<User, 'role' | 'enabled'>>) {
|
||||
export function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
role: UserRole,
|
||||
permissions?: Permission[]
|
||||
) {
|
||||
return http.post('/api/auth/users', { username, password, role, permissions });
|
||||
}
|
||||
|
||||
// rotateMcpKey asks the server to regenerate the device-wide MCP API key when
|
||||
// the change takes the mcp grant away; without it the key is kept.
|
||||
export function updateUser(
|
||||
username: string,
|
||||
data: Partial<Pick<User, 'role' | 'enabled' | 'permissions'>> & { rotateMcpKey?: boolean }
|
||||
) {
|
||||
return http.request({
|
||||
method: 'put',
|
||||
url: `/api/auth/users/${encodeURIComponent(username)}`,
|
||||
@@ -52,8 +102,13 @@ export function updateUser(username: string, data: Partial<Pick<User, 'role' | '
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteUser(username: string) {
|
||||
return http.delete(`/api/auth/users/${encodeURIComponent(username)}`);
|
||||
export function deleteUser(username: string, rotateMcpKey = false) {
|
||||
// DELETE carries no body the server reads, so the choice goes in the query.
|
||||
return http.request({
|
||||
method: 'delete',
|
||||
url: `/api/auth/users/${encodeURIComponent(username)}`,
|
||||
params: rotateMcpKey ? { rotateMcpKey: true } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function resetUserPassword(username: string, password: string) {
|
||||
|
||||
176
web/src/api/media.ts
Normal file
176
web/src/api/media.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
export type MediaType = 'image' | 'partition';
|
||||
|
||||
// What the emulated USB disk currently exposes to the target machine. "none"
|
||||
// is a medium that was really released, "unsupported" means the gadget has no
|
||||
// mass storage at all because the virtual disk is off or the device runs in
|
||||
// HID-only mode.
|
||||
export type MediaState = 'none' | 'image' | 'partition' | 'unsupported';
|
||||
|
||||
export interface MediaItem {
|
||||
type: MediaType;
|
||||
rel: string;
|
||||
name: string;
|
||||
size: number;
|
||||
fs: string;
|
||||
browsable: boolean;
|
||||
attached: boolean;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
// An optical image is one the emulated disk can carry as a CD-ROM: an ISO 9660
|
||||
// image or a UDF one, which the server reports under its own file system name.
|
||||
export function isOpticalImage(item: MediaItem): boolean {
|
||||
return item.fs === 'iso9660' || item.fs === 'udf';
|
||||
}
|
||||
|
||||
export interface MediaCapabilities {
|
||||
loop: boolean;
|
||||
}
|
||||
|
||||
export interface MediaStateInfo {
|
||||
state: MediaState;
|
||||
type?: MediaType;
|
||||
rel?: string;
|
||||
hidOnly: boolean;
|
||||
open: string[];
|
||||
// True while the attached medium is served as a CD-ROM.
|
||||
cdrom: boolean;
|
||||
}
|
||||
|
||||
export interface MediaFileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
isDir: boolean;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface MediaFiles {
|
||||
path: string;
|
||||
entries: MediaFileEntry[];
|
||||
free: number;
|
||||
total: number;
|
||||
// True while the medium is seeing writes: a zero length entry in that state
|
||||
// is a transfer in progress, not an empty file.
|
||||
writing?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaStatus {
|
||||
status: 'idle' | 'in_progress' | 'success' | 'failed';
|
||||
name: string;
|
||||
percentage: string;
|
||||
// Bytes transferred so far and the expected total; total is 0 while the
|
||||
// server does not know the size yet.
|
||||
loaded: number;
|
||||
total: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Error codes the media API reports; the UI reacts to these instead of
|
||||
// matching on message text.
|
||||
export const codeStorageDisabled = -10;
|
||||
export const codeMediumLocked = -11;
|
||||
export const codeMediumBusy = -12;
|
||||
export const codeNotBrowsable = -13;
|
||||
export const codeInsufficientSpace = -14;
|
||||
// A CD-ROM was asked for a medium that is not an ISO 9660 image.
|
||||
export const codeNotOptical = -15;
|
||||
|
||||
export function getMedia() {
|
||||
return http.get('/api/storage/media');
|
||||
}
|
||||
|
||||
export function getMediaState() {
|
||||
return http.get('/api/storage/media/state');
|
||||
}
|
||||
|
||||
export function getMediaStatus() {
|
||||
return http.get('/api/storage/media/status');
|
||||
}
|
||||
|
||||
export function deleteMedia(data: { type: MediaType; rel: string }) {
|
||||
return http.post('/api/storage/media/delete', data);
|
||||
}
|
||||
|
||||
export function mountMedia(data: {
|
||||
type: MediaType;
|
||||
rel: string;
|
||||
cdrom: boolean;
|
||||
force?: boolean;
|
||||
}) {
|
||||
return http.post('/api/storage/media/mount', data);
|
||||
}
|
||||
|
||||
export function ejectMedia(force = false) {
|
||||
return http.post('/api/storage/media/eject', { force });
|
||||
}
|
||||
|
||||
export function listMediaFiles(params: { type: MediaType; rel: string; path: string }) {
|
||||
return http.get('/api/storage/media/files', params);
|
||||
}
|
||||
|
||||
export function deleteMediaFile(data: { type: MediaType; rel: string; path: string }) {
|
||||
return http.post('/api/storage/media/files/delete', data);
|
||||
}
|
||||
|
||||
// enable/disable the emulated USB disk (Settings - Device). Without `enable`
|
||||
// the server toggles, which is only what a switch wants.
|
||||
export function setVirtualDevice(device: 'network' | 'disk', enable?: boolean) {
|
||||
return http.post(
|
||||
'/api/vm/device/virtual',
|
||||
enable === undefined ? { device } : { device, enable }
|
||||
);
|
||||
}
|
||||
|
||||
export function mediaQuery(type: MediaType, rel: string, path = ''): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('type', type);
|
||||
if (type === 'image') {
|
||||
params.set('rel', rel);
|
||||
}
|
||||
if (path) {
|
||||
params.set('path', path);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// Downloads go through the browser so it can show its own progress: an anchor
|
||||
// with the session cookie is all a file transfer needs.
|
||||
export function mediaFileDownloadUrl(type: MediaType, rel: string, path: string): string {
|
||||
return `${getBaseUrl('http')}/api/storage/media/files/download?${mediaQuery(type, rel, path)}`;
|
||||
}
|
||||
|
||||
export function mediaImageDownloadUrl(rel: string): string {
|
||||
return `${getBaseUrl('http')}/api/storage/media/download?${mediaQuery('image', rel)}`;
|
||||
}
|
||||
|
||||
// The fetch requests below carry the session cookie the way the axios client
|
||||
// does: VITE_WITH_CREDENTIALS=false is the only setting that leaves it out.
|
||||
export function fetchCredentials(): RequestCredentials {
|
||||
return import.meta.env.VITE_WITH_CREDENTIALS === 'false' ? 'omit' : 'include';
|
||||
}
|
||||
|
||||
// Uploads use fetch rather than the axios client: the request can outlive the
|
||||
// client's timeout, and the progress comes from the status endpoint.
|
||||
export async function uploadMediaFile(
|
||||
type: MediaType,
|
||||
rel: string,
|
||||
path: string,
|
||||
file: File,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ code: number; msg: string; data: unknown }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
|
||||
const url = `${getBaseUrl('http')}/api/storage/media/files/upload?${mediaQuery(type, rel, path)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: fetchCredentials(),
|
||||
signal
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// get image list
|
||||
export function getImages() {
|
||||
return http.get('/api/storage/image');
|
||||
}
|
||||
|
||||
// get mounted image
|
||||
export function getMountedImage() {
|
||||
return http.get('/api/storage/image/mounted');
|
||||
}
|
||||
|
||||
// mount/unmount image
|
||||
export function mountImage(file?: string, cdrom?: boolean) {
|
||||
const data = {
|
||||
file: file ? file : '',
|
||||
cdrom: cdrom
|
||||
};
|
||||
return http.post('/api/storage/image/mount', data);
|
||||
}
|
||||
|
||||
// get CD-ROM flag
|
||||
export function getCdRom() {
|
||||
return http.get('/api/storage/cdrom');
|
||||
}
|
||||
|
||||
export function deleteImage(file: string) {
|
||||
const data = {
|
||||
file
|
||||
};
|
||||
return http.post('/api/storage/image/delete', data);
|
||||
}
|
||||
@@ -2,11 +2,35 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* CJK punctuation (、。) exists in some Latin fonts that draw it above the
|
||||
baseline, so whichever font stack happens to claim those two characters
|
||||
decides whether a Chinese or Japanese sentence looks aligned. Pin them to a
|
||||
real CJK face: with unicode-range nothing else is affected, and when none of
|
||||
the local fonts exist the browser keeps using the rest of the stack. */
|
||||
@font-face {
|
||||
font-family: 'NanoKVM CJK punctuation';
|
||||
src:
|
||||
local('Noto Sans CJK SC'), local('Noto Sans SC'), local('Source Han Sans SC'),
|
||||
local('PingFang SC'), local('Hiragino Sans GB'), local('Microsoft YaHei');
|
||||
unicode-range: U+3001, U+3002;
|
||||
}
|
||||
|
||||
/* One stack for the whole app: the Tailwind-styled text and the antd components
|
||||
(tooltips, dialogs, selects) used to resolve CJK punctuation to different
|
||||
fonts. antd reads this variable through its font-family token. */
|
||||
:root {
|
||||
--app-font:
|
||||
'NanoKVM CJK punctuation', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
font-family: var(--app-font);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -1,15 +1,54 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AuthContext, useAuth } from '@/contexts/auth.ts';
|
||||
import { Spin } from 'antd';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Account, getAccount } from '@/api/auth.ts';
|
||||
import { AUTH_EXPIRED_EVENT } from '@/lib/auth-events.ts';
|
||||
import { Account, getAccount, Permission } from '@/api/auth.ts';
|
||||
import { AUTH_EXPIRED_EVENT, AUTH_REFRESH_EVENT } from '@/lib/auth-events.ts';
|
||||
|
||||
function toAccount(data: any): Account {
|
||||
return {
|
||||
username: data.username,
|
||||
role: data.role === 'admin' ? 'admin' : 'user',
|
||||
permissions: Array.isArray(data.permissions) ? data.permissions : []
|
||||
};
|
||||
}
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(true);
|
||||
const location = useLocation();
|
||||
|
||||
// refreshAccount keeps the account state in sync without disturbing the
|
||||
// page: a permission change must not look like a reload. It never clears the
|
||||
// account, so a transient failure leaves the last known state in place.
|
||||
//
|
||||
// Refreshes can overlap (a push, a rejected request and a focus change can
|
||||
// all ask at once), so only the latest request may set the state: an older
|
||||
// response arriving last would otherwise restore the grants it read.
|
||||
const refreshSequence = useRef(0);
|
||||
const refreshAccount = useCallback(() => {
|
||||
const sequence = ++refreshSequence.current;
|
||||
getAccount()
|
||||
.then((rsp) => {
|
||||
if (sequence !== refreshSequence.current) return;
|
||||
if (rsp.code !== 0 || !rsp.data?.username) return;
|
||||
const next = toAccount(rsp.data);
|
||||
setAccount((previous) => {
|
||||
if (
|
||||
previous &&
|
||||
previous.username === next.username &&
|
||||
previous.role === next.role &&
|
||||
previous.permissions.join() === next.permissions.join()
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -22,10 +61,7 @@ export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setAccount({
|
||||
username: rsp.data.username,
|
||||
role: rsp.data.role === 'admin' ? 'admin' : 'user'
|
||||
});
|
||||
setAccount(toAccount(rsp.data));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setIsAuthenticated(false);
|
||||
@@ -40,12 +76,55 @@ export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
|
||||
window.addEventListener(AUTH_REFRESH_EVENT, refreshAccount);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
|
||||
window.removeEventListener(AUTH_REFRESH_EVENT, refreshAccount);
|
||||
};
|
||||
}, []);
|
||||
}, [refreshAccount]);
|
||||
|
||||
// A page without a long-lived connection (the settings tabs, for instance)
|
||||
// cannot be pushed a permission change, so it re-reads the account when the
|
||||
// route changes or when the page becomes visible again.
|
||||
const isFirstLocation = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstLocation.current) {
|
||||
isFirstLocation.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshAccount();
|
||||
}, [location.pathname, refreshAccount]);
|
||||
|
||||
// Returning to the tab fires both focus and visibilitychange; one re-read
|
||||
// covers both.
|
||||
const lastVisibleRefresh = useRef(0);
|
||||
useEffect(() => {
|
||||
const handleVisible = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastVisibleRefresh.current < 1000) return;
|
||||
lastVisibleRefresh.current = now;
|
||||
refreshAccount();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleVisible);
|
||||
document.addEventListener('visibilitychange', handleVisible);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleVisible);
|
||||
document.removeEventListener('visibilitychange', handleVisible);
|
||||
};
|
||||
}, [refreshAccount]);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: Permission) =>
|
||||
account?.role === 'admin' || !!account?.permissions.includes(permission),
|
||||
[account]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -59,10 +138,19 @@ export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
|
||||
return <Navigate to={'/auth/login'} replace />;
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={{ account }}>{children}</AuthContext.Provider>;
|
||||
return <AuthContext.Provider value={{ account, hasPermission }}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const AdminRoute = ({ children }: { children: ReactNode }) => {
|
||||
const { account } = useAuth();
|
||||
return account.role === 'admin' ? children : <Navigate to="/" replace />;
|
||||
// PermissionRoute keeps a page behind a grant. Losing the grant while the page
|
||||
// is open sends the account back to the console, which then decides where it
|
||||
// can go; the alternative is a page whose every request is refused.
|
||||
export const PermissionRoute = ({
|
||||
permission,
|
||||
children
|
||||
}: {
|
||||
permission: Permission;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { hasPermission } = useAuth();
|
||||
return hasPermission(permission) ? children : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import icon from '@/assets/images/tailscale.svg';
|
||||
|
||||
export const Tailscale = () => {
|
||||
return <img src={icon} className="h-[18px] w-[18px]" alt="tailscale" />;
|
||||
export const Tailscale = ({ size = 18 }: { size?: number }) => {
|
||||
return <img src={icon} style={{ width: size, height: size }} alt="tailscale" />;
|
||||
};
|
||||
|
||||
171
web/src/components/permission-editor.tsx
Normal file
171
web/src/components/permission-editor.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Alert, Checkbox, Spin, Tooltip } from 'antd';
|
||||
import { CircleHelpIcon, ShieldAlertIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Permission, PermissionDescriptor } from '@/api/auth.ts';
|
||||
import {
|
||||
closureOf,
|
||||
GROUP_ORDER,
|
||||
groupLabelKey,
|
||||
permissionEffectKey,
|
||||
permissionLabelKey
|
||||
} from '@/lib/permissions.ts';
|
||||
|
||||
// The marker is the only risk signal left in the list: a plain question mark
|
||||
// explains what a grant does, and the amber shield marks the ones that amount to
|
||||
// full device control. Everything else stays in the tooltip, so thirteen grants
|
||||
// still fit on one screen.
|
||||
function markerOf(risk: PermissionDescriptor['risk']) {
|
||||
return risk === 'warn'
|
||||
? { Icon: ShieldAlertIcon, className: 'text-amber-500' }
|
||||
: { Icon: CircleHelpIcon, className: 'text-neutral-500' };
|
||||
}
|
||||
|
||||
type PermissionEditorProps = {
|
||||
// A default keeps the editor usable as an antd Form.Item child, which injects
|
||||
// value/onChange itself.
|
||||
value?: Permission[];
|
||||
// onChange is omitted for a read-only reference (an administrator holds every
|
||||
// grant, whatever its stored set says).
|
||||
onChange?: (permissions: Permission[]) => void;
|
||||
catalog: PermissionDescriptor[] | null;
|
||||
catalogFailed?: boolean;
|
||||
};
|
||||
|
||||
// PermissionEditor renders the catalog the server serves: its groups, its risk
|
||||
// marks and the grants one permission implies all come from there, so the editor
|
||||
// cannot offer a grant the server does not enforce or hide one it does.
|
||||
export const PermissionEditor = ({
|
||||
value = [],
|
||||
onChange,
|
||||
catalog,
|
||||
catalogFailed = false
|
||||
}: PermissionEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const labelOf = (permission: Permission) => t(permissionLabelKey(permission));
|
||||
const readOnly = !onChange;
|
||||
|
||||
if (catalogFailed) {
|
||||
return (
|
||||
<Alert type="error" showIcon message={t('settings.account.permissions.catalogFailed')} />
|
||||
);
|
||||
}
|
||||
if (!catalog) {
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Which selected grant brings each implied grant in, so the tooltip can say
|
||||
// where a checked-but-uneditable row comes from.
|
||||
const impliedSource = new Map<Permission, Permission>();
|
||||
for (const grant of value) {
|
||||
for (const implied of closureOf([grant], catalog)) {
|
||||
if (implied !== grant && !impliedSource.has(implied)) {
|
||||
impliedSource.set(implied, grant);
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolved = closureOf(value, catalog);
|
||||
|
||||
function toggle(permission: Permission, checked: boolean) {
|
||||
if (!onChange) return;
|
||||
onChange(checked ? [...value, permission] : value.filter((granted) => granted !== permission));
|
||||
}
|
||||
|
||||
const sections = GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
items: catalog.filter((descriptor) => descriptor.group === group)
|
||||
})).filter((section) => section.items.length > 0);
|
||||
|
||||
const fullControl = catalog
|
||||
.filter((descriptor) => descriptor.risk === 'warn' && resolved.has(descriptor.id))
|
||||
.map((descriptor) => labelOf(descriptor.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex max-h-[55vh] flex-col gap-3 overflow-auto pr-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.group}>
|
||||
<div className="mb-1 text-xs text-neutral-500">{t(groupLabelKey(section.group))}</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{section.items.map((descriptor) => {
|
||||
const granted = value.includes(descriptor.id);
|
||||
const source = impliedSource.get(descriptor.id);
|
||||
// A grant another selection already brings in is locked even when
|
||||
// the account also stores it explicitly: unchecking it would only
|
||||
// remove the stored copy and leave the closure to add it back, so
|
||||
// the box must not offer an edit that cannot take effect.
|
||||
const locked = source !== undefined;
|
||||
// The note explains a box that is checked on someone else's
|
||||
// account only. A grant the account stores itself is checked in
|
||||
// its own right, so naming a source would describe it backwards.
|
||||
const includedBy = !granted && source;
|
||||
const { Icon, className: markerClass } = markerOf(descriptor.risk);
|
||||
const effect = permissionEffectKey(descriptor.id);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
key={descriptor.id}
|
||||
checked={granted || locked}
|
||||
disabled={readOnly || locked}
|
||||
onChange={(event) => toggle(descriptor.id, event.target.checked)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{labelOf(descriptor.id)}
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
{effect ? t(effect) : labelOf(descriptor.id)}
|
||||
{includedBy && source && (
|
||||
<span className="mt-1 block text-amber-300">
|
||||
{t('settings.account.permissions.impliedBy', {
|
||||
name: labelOf(source)
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{/* The icon sits inside the label, so it must not toggle the box */}
|
||||
<span
|
||||
className={`inline-flex shrink-0 cursor-help items-center ${markerClass}`}
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* One line, below the grants: the marks above already say where it comes from */}
|
||||
{fullControl.length > 0 && (
|
||||
<div className="flex items-start gap-1 text-xs text-amber-500">
|
||||
<ShieldAlertIcon size={12} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{t('settings.account.permissions.fullControlWarning')} {fullControl.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.length === 0 && (
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('settings.account.permissions.emptyWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
viewportRef,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={clsx('relative', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px]"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={clsx(
|
||||
'flex touch-none select-none p-px transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2 flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full bg-neutral-500/30"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import { Account } from '@/api/auth.ts';
|
||||
import { Account, Permission } from '@/api/auth.ts';
|
||||
|
||||
export type AuthState = {
|
||||
account: Account;
|
||||
// hasPermission reports whether the account effectively holds the grant. The
|
||||
// server resolves implied grants into the account it serves, so a call site
|
||||
// never has to check a pair (library.read is implied by both library.write
|
||||
// and medium.attach, for instance).
|
||||
hasPermission: (permission: Permission) => boolean;
|
||||
};
|
||||
|
||||
export const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
94
web/src/hooks/useMediaMount.ts
Normal file
94
web/src/hooks/useMediaMount.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/media.ts';
|
||||
|
||||
type Options = {
|
||||
onError: (message: string, description?: string) => void;
|
||||
// The gadget has no mass storage because the virtual disk is switched off.
|
||||
// The page offers to turn it on, the menu panel just says where the setting
|
||||
// lives: a confirm dialog would be dismissed by the popover closing.
|
||||
onNeedEnableDisk?: (retry: () => void) => void;
|
||||
// The target machine is holding the medium, so taking it back means
|
||||
// re-enumerating the device under a machine that is using it. Only the
|
||||
// caller can ask whether that is acceptable.
|
||||
onNeedForce?: (retry: () => void) => void;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
// Retry as a forced operation, which the target machine cannot refuse.
|
||||
force?: boolean;
|
||||
// False once the user has already been asked.
|
||||
prompt?: boolean;
|
||||
};
|
||||
|
||||
// Mounting and ejecting is the one media action both the desktop menu and the
|
||||
// storage page offer.
|
||||
export function useMediaMount({ onError, onNeedEnableDisk, onNeedForce, onChanged }: Options) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
// Every action is retried the same way: the first attempt is the polite one,
|
||||
// and a refusal the user can act on is handed to the caller to confirm.
|
||||
async function run(
|
||||
key: string,
|
||||
action: (force: boolean) => Promise<{ code: number; msg: string }>,
|
||||
opts: Action,
|
||||
retry: (opts: Action) => void
|
||||
) {
|
||||
setBusy(key);
|
||||
|
||||
let rsp;
|
||||
try {
|
||||
rsp = await action(opts.force ?? false);
|
||||
} catch {
|
||||
onError(t('storage.failed'));
|
||||
return;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
|
||||
if (rsp.code === 0) {
|
||||
onChanged?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const mayPrompt = opts.prompt !== false;
|
||||
if (rsp.code === api.codeStorageDisabled && mayPrompt && onNeedEnableDisk) {
|
||||
onNeedEnableDisk(() => retry({ ...opts, prompt: false }));
|
||||
return;
|
||||
}
|
||||
if (rsp.code === api.codeMediumLocked && mayPrompt && onNeedForce) {
|
||||
onNeedForce(() => retry({ ...opts, force: true, prompt: false }));
|
||||
return;
|
||||
}
|
||||
if (rsp.code === api.codeNotOptical) {
|
||||
// The list the page was showing is older than the medium it names.
|
||||
onError(t('storage.add.cdromOnlyIso'));
|
||||
return;
|
||||
}
|
||||
|
||||
onError(rsp.msg);
|
||||
}
|
||||
|
||||
function mount(item: api.MediaItem, cdrom = false, opts: Action = {}) {
|
||||
return run(
|
||||
`${item.type}:${item.rel}`,
|
||||
(force) => api.mountMedia({ type: item.type, rel: item.rel, cdrom, force }),
|
||||
opts,
|
||||
(next) => void mount(item, cdrom, next)
|
||||
);
|
||||
}
|
||||
|
||||
function eject(opts: Action = {}) {
|
||||
return run(
|
||||
'eject',
|
||||
(force) => api.ejectMedia(force),
|
||||
opts,
|
||||
(next) => void eject(next)
|
||||
);
|
||||
}
|
||||
|
||||
return { mount, eject, busy };
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const ca = {
|
||||
login: 'Inici de sessió',
|
||||
changePassword: 'Canviar contrasenya',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Imatges i compartició de fitxers'
|
||||
},
|
||||
auth: {
|
||||
login: 'Inici de sessió',
|
||||
@@ -39,7 +40,10 @@ const ca = {
|
||||
change2: "Contrasenya d'inici de sessió web",
|
||||
change3: 'Contrasenya root del sistema (inici de sessió SSH)',
|
||||
change4: 'Per restablir les contrasenyes, mantingues premut el botó BOOT del NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Contrasenya actual',
|
||||
passwordLength: 'La contrasenya ha de tenir entre 8 i 72 caràcters',
|
||||
invalidCurrentPassword: 'La contrasenya actual no és correcta'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const ca = {
|
||||
verifyBtn: 'Verificar'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Només lectura',
|
||||
readOnlyTip:
|
||||
'Aquest compte no pot enviar entrada de teclat ni de ratolí a la màquina objectiu, de manera que la consola només mostra la pantalla.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Aquest compte no té accés a la consola',
|
||||
description:
|
||||
'Se li han concedit altres capacitats. Obriu una de les pàgines que pot usar o demaneu a un administrador accés a la consola.',
|
||||
nothing: 'Aquest compte encara no té permisos. Demaneu a un administrador que n’atorgui.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Escala',
|
||||
title: 'Pantalla',
|
||||
@@ -213,32 +228,44 @@ const ca = {
|
||||
disable: 'Desactiva mode només HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Imatges',
|
||||
loading: 'Carregant...',
|
||||
empty: "No s'ha trobat res",
|
||||
storage: {
|
||||
title: 'Imatges i compartició de fitxers',
|
||||
empty:
|
||||
"Encara no hi ha imatges. Puja una ISO o una IMG, o copia'n una al disc des de l'ordinador remot.",
|
||||
notAttached: 'Cap dispositiu muntat',
|
||||
attached: 'Muntat: {{name}}',
|
||||
eject: 'Expulsa',
|
||||
mount: 'Munta',
|
||||
mountMode: 'Mode de muntatge',
|
||||
mountFailed: 'Error en muntar',
|
||||
mountDesc: 'En alguns sistemes cal expulsar el disc virtual abans de muntar la imatge.',
|
||||
diskOnWarn:
|
||||
"Muntar la imatge desactiva el disc virtual, de manera que l'ordinador ja no pot accedir a /data. Voleu continuar?",
|
||||
diskOffWarn:
|
||||
"El disc virtual està desactivat i cal activar-lo per muntar la imatge. Els dispositius USB de l'ordinador es tornaran a enumerar. Voleu continuar?",
|
||||
diskShared:
|
||||
'El disc virtual està activat, així que /data és només de lectura aquí. Desactiveu-lo per suprimir o pujar imatges.',
|
||||
unmountFailed: "No s'ha pogut desmuntar",
|
||||
unmountDesc:
|
||||
"En alguns sistemes, cal expulsar manualment de l'amfitrió remot abans de desmuntar la imatge.",
|
||||
refresh: 'Actualitza la llista',
|
||||
attention: 'Atenció',
|
||||
deleteConfirm: 'Esteu segur que voleu suprimir aquesta imatge?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No',
|
||||
mountModeDisk: 'Emmagatzematge massiu',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Obre el gestor de fitxers',
|
||||
refresh: 'Actualitza',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina el suport',
|
||||
deleteConfirm: 'Voleu eliminar {{name}}? Els fitxers que conté es perdran.',
|
||||
downloadImage: 'Baixa la imatge',
|
||||
typePartition: 'Fitxers compartits',
|
||||
volumeLabel: 'Disc extraïble',
|
||||
groups: {
|
||||
share: 'Compartició de fitxers',
|
||||
images: 'Imatges'
|
||||
},
|
||||
failed: "L'operació ha fallat",
|
||||
hidOnly: 'Mode només HID: el disc virtual no està disponible',
|
||||
disabled: 'El disc virtual està desactivat (Configuració - Dispositiu)',
|
||||
force: {
|
||||
title: 'Voleu recuperar el suport per la força?',
|
||||
desc: "L'ordinador remot encara fa servir aquest suport. Recuperar-lo torna a enumerar el dispositiu USB i pot perdre tot el que encara no ha escrit al disc.",
|
||||
menuHint:
|
||||
"L'ordinador remot fa servir aquest suport. Obriu el gestor de fitxers per recuperar-lo per la força."
|
||||
},
|
||||
tips: {
|
||||
title: 'Com pujar imatges',
|
||||
usb1: 'Connecta el NanoKVM al teu ordinador via USB.',
|
||||
usb2: "Assegura't que el disc virtual està muntat (Configuració - Disc Virtual).",
|
||||
usb2: "En aquesta pàgina, munta els fitxers compartits en mode d'emmagatzematge massiu.",
|
||||
usb3: "Obre el disc virtual i copia la imatge a l'arrel.",
|
||||
usb4: 'Expulsa el disc quan acabi la còpia: la imatge apareixerà llavors a la llista.',
|
||||
scp1: 'Comprova que el NanoKVM i el teu ordinador estan a la mateixa xarxa.',
|
||||
scp2: 'Obre un terminal i usa SCP per pujar la imatge al directori /data del NanoKVM.',
|
||||
scp3: 'Exemple: scp ruta-de-la-imatge root@ip-del-nanokvm:/data',
|
||||
@@ -248,7 +275,69 @@ const ca = {
|
||||
tf3: "Introdueix la targeta en un lector i connecta'l a l'ordinador.",
|
||||
tf4: 'Copia la imatge al directori /data de la targeta.',
|
||||
tf5: 'Reintrodueix la targeta al NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: "S'està processant {{name}}"
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Voleu activar el disc virtual?',
|
||||
desc: "Per muntar un suport cal el disc virtual, que ara està desactivat. Voleu activar-lo? El teclat i el ratolí de l'ordinador remot es desconnectaran un moment.",
|
||||
menuHint: 'El disc virtual està desactivat. Activeu-lo a Configuració - Dispositiu.',
|
||||
askDevice:
|
||||
"El disc virtual està desactivat. Cal que un compte amb el permís de dispositiu l'activi primer."
|
||||
},
|
||||
add: {
|
||||
title: 'Afegeix un suport',
|
||||
upload: 'Puja una ISO o IMG',
|
||||
fromUrl: "Baixa des d'una URL",
|
||||
url: 'URL de la imatge',
|
||||
urlRequired: 'Introduïu una URL',
|
||||
onlyImages: 'Només es poden muntar fitxers .iso i .img',
|
||||
dropHint: 'Arrossegueu una ISO o IMG aquí, o feu clic per triar un fitxer',
|
||||
cdromOnlyIso: 'Un CD-ROM només pot contenir una imatge ISO 9660',
|
||||
uploadFailed: 'La pujada ha fallat',
|
||||
downloadFailed: 'La baixada ha fallat',
|
||||
uploading: "S'està pujant {{name}}",
|
||||
downloading: "S'està baixant {{name}}",
|
||||
done: '{{name}} està llest',
|
||||
checksumFailed: 'La imatge no coincideix amb la suma de verificació SHA-256.',
|
||||
blocked: "L'ordinador remot fa servir el disc",
|
||||
copyHint:
|
||||
"També podeu copiar una imatge a la partició de dades per SCP, o des de l'ordinador remot."
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Seleccioneu un suport per veure els fitxers que conté.',
|
||||
reasonAttached: "Aquest suport està connectat a l'ordinador remot.",
|
||||
reasonAttachedHint:
|
||||
"Expulseu-lo primer: l'ordinador remot ha d'alliberar el disc abans que se'n puguin llegir els fitxers.",
|
||||
reasonNotBrowsable: 'Aquest suport conté un sistema de fitxers que el gestor no pot obrir.',
|
||||
reasonNoLoop: 'Aquest dispositiu no pot muntar imatges de disc (sense suport de loop).',
|
||||
reasonISOImage:
|
||||
"Una imatge ISO 9660 no es pot navegar aquí. Es pot muntar igualment a l'ordinador remot.",
|
||||
empty: 'Aquesta carpeta és buida',
|
||||
noMatch: 'Cap fitxer coincideix amb la cerca.',
|
||||
free: '{{size}} lliures',
|
||||
search: 'Cerca en aquesta carpeta',
|
||||
sort: {
|
||||
name: 'Nom',
|
||||
size: 'Mida',
|
||||
type: 'Tipus',
|
||||
asc: 'Ascendent',
|
||||
desc: 'Descendent'
|
||||
},
|
||||
upload: 'Puja un fitxer al suport',
|
||||
deleteFolderTitle: 'Suprimeix la carpeta',
|
||||
deleteFolderConfirm:
|
||||
'Voleu suprimir la carpeta {{name}} i tot el que conté? Aquesta acció no es pot desfer.',
|
||||
uploading: "S'està pujant…",
|
||||
uploadFailed: 'La pujada ha fallat',
|
||||
download: 'Baixa',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina el fitxer',
|
||||
deleteConfirm: 'Voleu eliminar {{name}} del suport?'
|
||||
},
|
||||
yes: 'Sí',
|
||||
no: 'No'
|
||||
},
|
||||
script: {
|
||||
title: 'Scripts',
|
||||
@@ -290,21 +379,11 @@ const ca = {
|
||||
ok: "D'acord"
|
||||
},
|
||||
download: {
|
||||
title: "Descarregador d'imatges",
|
||||
input: 'Introdueix la URL de la imatge',
|
||||
ok: "D'acord",
|
||||
disabled: 'La partició /data és només lectura. No es pot descarregar la imatge.',
|
||||
diskShared: "Apagueu primer el disc virtual i després useu la descàrrega d'imatges.",
|
||||
diskOff: 'Apaga el disc virtual',
|
||||
uploadbox: 'Deixeu anar el fitxer aquí o feu clic per seleccionar-lo',
|
||||
inputfile: "Introduïu el fitxer d'imatge",
|
||||
NoISO: 'Cap ISO',
|
||||
disabled:
|
||||
"La partició de dades és només de lectura mentre l'ordinador remot la fa servir: no s'hi poden escriure imatges noves, però els fitxers existents es poden baixar.",
|
||||
sha256: 'SHA-256 (opcional)',
|
||||
sha256Placeholder: 'Introduïu una suma de verificació SHA-256 de 64 caràcters',
|
||||
invalidSHA256: 'SHA-256 ha de ser una cadena hexadecimal de 64 caràcters',
|
||||
failed: 'Descàrrega fallida',
|
||||
success: 'Descàrrega correcta',
|
||||
checksumFailed: 'Descàrrega fallida: ha fallat la verificació SHA-256',
|
||||
cancel: 'Cancel·la',
|
||||
cancelFailed: 'No sha pogut cancel·lar la descàrrega'
|
||||
},
|
||||
@@ -454,17 +533,19 @@ const ca = {
|
||||
hidOnlyDesc: "Deixeu d'emular dispositius virtuals, conservant només el control bàsic HID",
|
||||
disk: 'Disc virtual',
|
||||
diskDesc: 'Munta un disc U virtual al dispositiu remot',
|
||||
diskLocked: 'Desactivat mentre hi ha una imatge muntada (activar-lo cancel·la el muntatge)',
|
||||
diskCancelMount:
|
||||
"Activar el disc virtual cancel·la la imatge muntada i interromp una descàrrega o pujada d'imatge en curs. Voleu continuar?",
|
||||
diskBreakDownload:
|
||||
"Activar el disc virtual interromp la descàrrega d'imatges. Voleu continuar?",
|
||||
network: 'Xarxa virtual',
|
||||
networkDesc: 'Munta una targeta de xarxa virtual al dispositiu remot',
|
||||
reboot: 'Reinicia',
|
||||
rebootDesc: 'Segur que vols reiniciar el NanoKVM?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
diskLocked: 'Desactivat mentre hi ha una imatge muntada (activar-lo cancel·la el muntatge)',
|
||||
diskCancelMount:
|
||||
"Activar el disc virtual cancel·la la imatge muntada i interromp una descàrrega o pujada d'imatge en curs. Voleu continuar?",
|
||||
diskBreakDownload:
|
||||
"Activar el disc virtual interromp la descàrrega d'imatges. Voleu continuar?",
|
||||
attention: 'Atenció',
|
||||
updateFailed: "No s'ha pogut actualitzar el dispositiu virtual"
|
||||
},
|
||||
network: {
|
||||
title: 'Xarxa',
|
||||
@@ -603,7 +684,90 @@ const ca = {
|
||||
logoutBtn: 'Tanca sessió',
|
||||
logoutDesc: 'Segur que vols tancar sessió?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
role: 'Rol',
|
||||
roles: {
|
||||
admin: 'Administrador',
|
||||
user: 'Usuari'
|
||||
},
|
||||
users: {
|
||||
title: 'Usuaris',
|
||||
create: 'Crea un usuari',
|
||||
enabled: 'Activat',
|
||||
disabled: 'Desactivat',
|
||||
deviceOwner: 'Propietari del dispositiu',
|
||||
resetPassword: 'Restableix la contrasenya',
|
||||
delete: 'Elimina',
|
||||
deleteConfirm: 'Voleu eliminar aquest usuari i revocar totes les seves sessions?',
|
||||
created: 'Usuari creat',
|
||||
deleted: 'Usuari eliminat',
|
||||
passwordUpdated: 'Contrasenya actualitzada',
|
||||
loadFailed: "No s'han pogut carregar els usuaris",
|
||||
saveFailed: "No s'ha pogut desar l'usuari",
|
||||
deleteFailed: "No s'ha pogut eliminar l'usuari",
|
||||
editPermissions: 'Permisos',
|
||||
mcpLossTitle: 'Retirar l’accés MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} perdrà l’accés MCP. La clau API de MCP la comparteix tot el dispositiu, així que una còpia continua funcionant fins que es regeneri. Regenerar-la desconnecta tots els clients MCP fins que rebin la clau nova.',
|
||||
mcpLossRotate: 'Regenera la clau API de MCP',
|
||||
mcpLossKeep:
|
||||
'La clau actual continua sent vàlida i qui en tingui una còpia encara pot controlar el teclat i el ratolí. La podeu regenerar més tard a la configuració de MCP.',
|
||||
mcpKeyRotated: 'Clau API de MCP regenerada. Els clients MCP necessiten la clau nova.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Permisos',
|
||||
editTitle: 'Edita els permisos',
|
||||
save: 'Desa',
|
||||
saved: 'Desat',
|
||||
close: 'Tanca',
|
||||
groupConsole: 'Consola',
|
||||
groupTarget: 'Màquina de destí',
|
||||
groupLibrary: 'Biblioteca de mitjans',
|
||||
groupHost: 'Aquest dispositiu',
|
||||
groupServices: 'Serveis',
|
||||
consoleView: 'Veure la pantalla',
|
||||
consoleControl: 'Teclat i ratolí',
|
||||
power: 'Engegada i despertar',
|
||||
mediumAttach: 'Connecta un mitjà',
|
||||
libraryRead: 'Navega per la biblioteca',
|
||||
libraryWrite: 'Gestiona la biblioteca',
|
||||
terminal: 'Terminal i scripts',
|
||||
device: 'Configuració del dispositiu',
|
||||
network: 'Configuració de xarxa',
|
||||
update: 'Actualització del sistema',
|
||||
mcp: 'Servei MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Assistent d’IA',
|
||||
effectConsoleView:
|
||||
'Mostra la pantalla de destí i defineix el mode de vídeo, la resolució, els fps i la qualitat que comparteixen tots els espectadors.',
|
||||
effectConsoleControl: 'Envia entrada de teclat i ratolí a la màquina de destí.',
|
||||
effectPower:
|
||||
'Engega, apaga i reinicia la màquina de destí, i gestiona la llista de dispositius de Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Munta i expulsa mitjans a la màquina de destí, i hi intercanvia fitxers mitjançant la partició compartida.',
|
||||
effectLibraryRead: 'Llista i baixa les imatges i els fitxers que contenen.',
|
||||
effectLibraryWrite: 'Puja, crea i elimina imatges, i modifica els fitxers que contenen.',
|
||||
effectTerminal:
|
||||
'Dona un shell de root en aquest dispositiu, sense passar per la sessió web.',
|
||||
effectDevice:
|
||||
'Permet activar SSH (també root), reiniciar el dispositiu, lliurar la partició de dades en brut a la màquina de destí i canviar OLED, HDMI, mDNS, memòria i swap.',
|
||||
effectNetwork:
|
||||
'Canvia Wi-Fi, DNS i TLS, cosa que pot deixar aquest dispositiu sense xarxa.',
|
||||
effectUpdate: 'Instal·la firmware i paquets d’aplicació.',
|
||||
effectMcp:
|
||||
'Emet una clau API de tot el dispositiu que controla teclat, ratolí i captures fora de la sessió web; una còpia continua funcionant després de revocar el permís si no es regenera la clau, cosa que s’ofereix en retirar el permís.',
|
||||
effectTailscale: 'Uneix aquest dispositiu a una malla VPN.',
|
||||
effectAi:
|
||||
'Controla el dispositiu mitjançant una interfície local que els altres permisos no limiten, així que equival al control total del dispositiu.',
|
||||
fullControlWarning: 'Aquests permisos equivalen al control total del dispositiu:',
|
||||
emptyWarning: 'Aquest compte no tindrà cap funció i només podrà iniciar sessió.',
|
||||
impliedBy: 'Inclòs per {{name}}',
|
||||
grantCount_one: '{{count}} permís',
|
||||
grantCount: '{{count}} permisos',
|
||||
adminAll: 'Els administradors tenen tots els permisos',
|
||||
catalogFailed:
|
||||
'No s’ha pogut carregar la llista de permisos. Tanqueu aquest diàleg i torneu-ho a provar.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const cz = {
|
||||
login: 'Přihlášení',
|
||||
changePassword: 'Změna hesla',
|
||||
terminal: 'Terminál',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Obrázky a sdílení souborů'
|
||||
},
|
||||
auth: {
|
||||
login: 'Přihlášení',
|
||||
@@ -40,7 +41,10 @@ const cz = {
|
||||
change2: 'Heslo pro webové přihlášení',
|
||||
change3: 'Heslo systémového uživatele root (heslo pro přihlášení SSH)',
|
||||
change4: 'Chcete-li hesla resetovat, podržte tlačítko BOOT na NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Současné heslo',
|
||||
passwordLength: 'Heslo musí mít 8 až 72 znaků',
|
||||
invalidCurrentPassword: 'Současné heslo není správné'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const cz = {
|
||||
verifyBtn: 'Ověřte'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Pouze prohlížení',
|
||||
readOnlyTip:
|
||||
'Tento účet nemůže odesílat vstup z klávesnice a myši do cílového počítače, proto konzole pouze zobrazuje obrazovku.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Tento účet nemá přístup ke konzoli',
|
||||
description:
|
||||
'Byla mu udělena jiná oprávnění. Otevřete některou ze stránek, které může používat, nebo požádejte administrátora o přístup ke konzoli.',
|
||||
nothing: 'Tento účet zatím nemá žádná oprávnění. Požádejte administrátora o jejich udělení.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Měřítko',
|
||||
title: 'Obrazovka',
|
||||
@@ -214,33 +229,44 @@ const cz = {
|
||||
disable: 'Zakázat režim HID-Only'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Obrázky',
|
||||
loading: 'Načítání...',
|
||||
empty: 'Nic nenalezeno',
|
||||
storage: {
|
||||
title: 'Obrázky a sdílení souborů',
|
||||
empty:
|
||||
'Zatím žádné obrazy. Nahrajte ISO nebo IMG, nebo zkopírujte obraz na disk ze vzdáleného počítače.',
|
||||
notAttached: 'Nic není připojeno',
|
||||
attached: 'Připojeno: {{name}}',
|
||||
eject: 'Vysunout',
|
||||
mount: 'Připojit',
|
||||
mountMode: 'Režim připojení',
|
||||
mountFailed: 'Připojení se nezdařilo',
|
||||
mountDesc:
|
||||
'V některých systémech je nutné před připojením obrazu vysunout virtuální disk na vzdáleném hostiteli.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Odpojení se nezdařilo',
|
||||
unmountDesc:
|
||||
'Na některých systémech se musíte před odpojením obrazu ručně vysunout ze vzdáleného hostitele.',
|
||||
refresh: 'Obnovte seznam obrázků',
|
||||
attention: 'Pozor',
|
||||
deleteConfirm: 'Opravdu chcete smazat tento obrázek?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne',
|
||||
mountModeDisk: 'Hromadné úložiště',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Otevřít správce souborů',
|
||||
refresh: 'Obnovit',
|
||||
delete: 'Smazat',
|
||||
deleteTitle: 'Smazat médium',
|
||||
deleteConfirm: 'Smazat {{name}}? Soubory v něm budou ztraceny.',
|
||||
downloadImage: 'Stáhnout obraz',
|
||||
typePartition: 'Sdílené soubory',
|
||||
volumeLabel: 'Vyměnitelný disk',
|
||||
groups: {
|
||||
share: 'Sdílení souborů',
|
||||
images: 'Obrázky'
|
||||
},
|
||||
failed: 'Operace se nezdařila',
|
||||
hidOnly: 'Režim pouze HID: virtuální disk není k dispozici',
|
||||
disabled: 'Virtuální disk je vypnutý (Nastavení - Zařízení)',
|
||||
force: {
|
||||
title: 'Vzít médium zpět násilím?',
|
||||
desc: 'Vzdálený počítač toto médium stále používá. Vzetí zpět znovu vyjmenuje zařízení USB a může ztratit vše, co ještě nezapsal na disk.',
|
||||
menuHint:
|
||||
'Vzdálený počítač toto médium používá. Otevřete správce souborů a vezměte ho zpět násilím.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Jak nahrát',
|
||||
usb1: 'Připojte NanoKVM k vašemu počítači přes USB.',
|
||||
usb2: 'Ujistěte se, že je virtuální disk připojen (Nastavení - Virtuální disk).',
|
||||
usb2: 'Na této stránce připojte sdílené soubory v režimu velkokapacitního úložiště.',
|
||||
usb3: 'Otevřete virtuální disk na vašem počítači a zkopírujte soubor s obrazem do kořenového adresáře virtuálního disku.',
|
||||
usb4: 'Po dokončení kopírování disk vysuňte: obraz se pak objeví v seznamu.',
|
||||
scp1: 'Ujistěte se, že jsou NanoKVM a váš počítač ve stejné místní síti.',
|
||||
scp2: 'Otevřete terminál na vašem počítači a použijte příkaz SCP pro nahrání souboru s obrazem do adresáře /data na zařízení NanoKVM.',
|
||||
scp3: 'Příklad: scp cesta-k-vašemu-obrazu root@ip-nanokvm:/data',
|
||||
@@ -250,7 +276,69 @@ const cz = {
|
||||
tf3: 'Vložte SD kartu do čtečky karet a připojte ji k vašemu počítači.',
|
||||
tf4: 'Zkopírujte soubor s obrazem do adresáře /data na SD kartě.',
|
||||
tf5: 'Vložte SD kartu zpět do NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Zpracovává se {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Zapnout virtuální disk?',
|
||||
desc: 'K připojení média je potřeba virtuální disk, který je nyní vypnutý. Zapnout ho? Klávesnice a myš vzdáleného počítače se na okamžik odpojí.',
|
||||
menuHint: 'Virtuální disk je vypnutý. Zapněte ho v Nastavení - Zařízení.',
|
||||
askDevice:
|
||||
'Virtuální disk je vypnutý. Nejprve jej musí zapnout účet s oprávněním pro zařízení.'
|
||||
},
|
||||
add: {
|
||||
title: 'Přidat médium',
|
||||
upload: 'Nahrát ISO nebo IMG',
|
||||
fromUrl: 'Stáhnout z adresy URL',
|
||||
url: 'Adresa URL obrazu',
|
||||
urlRequired: 'Zadejte adresu URL',
|
||||
onlyImages: 'Připojit lze pouze soubory .iso a .img',
|
||||
dropHint: 'Přetáhněte sem ISO nebo IMG, nebo klikněte pro výběr souboru',
|
||||
cdromOnlyIso: 'CD-ROM může obsahovat pouze obraz ISO 9660',
|
||||
uploadFailed: 'Nahrání se nezdařilo',
|
||||
downloadFailed: 'Stažení se nezdařilo',
|
||||
uploading: 'Nahrávání {{name}}',
|
||||
downloading: 'Stahování {{name}}',
|
||||
done: '{{name}} je připraven',
|
||||
checksumFailed: 'Obraz neodpovídá kontrolnímu součtu SHA-256.',
|
||||
blocked: 'Vzdálený počítač disk používá',
|
||||
copyHint:
|
||||
'Obraz můžete také zkopírovat do datového oddílu přes SCP nebo ze vzdáleného počítače.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Vyberte médium a prohlédněte si soubory v něm.',
|
||||
reasonAttached: 'Toto médium je připojeno ke vzdálenému počítači.',
|
||||
reasonAttachedHint:
|
||||
'Nejprve ho vysuňte: vzdálený počítač musí disk uvolnit, než bude možné jeho soubory přečíst.',
|
||||
reasonNotBrowsable:
|
||||
'Toto médium obsahuje souborový systém, který správce nedokáže otevřít.',
|
||||
reasonNoLoop: 'Toto zařízení nedokáže připojit obrazy disků (chybí podpora loop).',
|
||||
reasonISOImage:
|
||||
'Obraz ISO 9660 zde nelze procházet. Lze ho ale připojit ke vzdálenému počítači.',
|
||||
empty: 'Tato složka je prázdná',
|
||||
noMatch: 'Vyhledávání neodpovídá žádný soubor.',
|
||||
free: '{{size}} volných',
|
||||
search: 'Hledat v této složce',
|
||||
sort: {
|
||||
name: 'Název',
|
||||
size: 'Velikost',
|
||||
type: 'Typ',
|
||||
asc: 'Vzestupně',
|
||||
desc: 'Sestupně'
|
||||
},
|
||||
upload: 'Nahrát soubor do média',
|
||||
deleteFolderTitle: 'Smazat složku',
|
||||
deleteFolderConfirm: 'Smazat složku {{name}} a celý její obsah? Tuto akci nelze vrátit.',
|
||||
uploading: 'Nahrávání…',
|
||||
uploadFailed: 'Nahrání se nezdařilo',
|
||||
download: 'Stáhnout',
|
||||
delete: 'Smazat',
|
||||
deleteTitle: 'Smazat soubor',
|
||||
deleteConfirm: 'Smazat {{name}} z média?'
|
||||
},
|
||||
yes: 'Ano',
|
||||
no: 'Ne'
|
||||
},
|
||||
script: {
|
||||
title: 'Skript',
|
||||
@@ -292,21 +380,11 @@ const cz = {
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'Stahovač obrazů',
|
||||
input: 'Zadejte prosím vzdálený obrázek URL',
|
||||
ok: 'OK',
|
||||
disabled: 'Oddíl /data je RO, takže obrázek nelze stáhnout',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Přetáhněte soubor sem nebo kliknutím vyberte',
|
||||
inputfile: 'Zadejte soubor obrázku',
|
||||
NoISO: 'Žádné ISO',
|
||||
disabled:
|
||||
'Datový oddíl je po dobu používání vzdáleným počítačem pouze pro čtení: nové obrazy nelze zapsat, stávající soubory lze stále stáhnout.',
|
||||
sha256: 'SHA-256 (volitelné)',
|
||||
sha256Placeholder: 'Zadejte 64znakový kontrolní součet SHA-256',
|
||||
invalidSHA256: 'SHA-256 musí být 64znakový hexadecimální řetězec',
|
||||
failed: 'Stažení se nezdařilo',
|
||||
success: 'Stažení proběhlo úspěšně',
|
||||
checksumFailed: 'Stažení se nezdařilo: ověření SHA-256 selhalo',
|
||||
cancel: 'Zrušit',
|
||||
cancelFailed: 'Stažení se nepodařilo zrušit'
|
||||
},
|
||||
@@ -455,16 +533,18 @@ const cz = {
|
||||
hidOnlyDesc: 'Zastavit emulaci virtuálních zařízení a zachovat pouze základní ovládání HID',
|
||||
disk: 'Virtuální disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuální síť',
|
||||
networkDesc: 'Připojit virtuální síťovou kartu na vzdáleném hostiteli',
|
||||
reboot: 'Restartujte',
|
||||
rebootDesc: 'Opravdu chcete restartovat NanoKVM?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne'
|
||||
cancelBtn: 'Ne',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Pozor',
|
||||
updateFailed: 'Aktualizace virtuálního zařízení se nezdařila'
|
||||
},
|
||||
network: {
|
||||
title: 'Síť',
|
||||
@@ -603,7 +683,87 @@ const cz = {
|
||||
logoutBtn: 'Odhlásit',
|
||||
logoutDesc: 'Opravdu se chcete odhlásit?',
|
||||
okBtn: 'Ano',
|
||||
cancelBtn: 'Ne'
|
||||
cancelBtn: 'Ne',
|
||||
role: 'Role',
|
||||
roles: {
|
||||
admin: 'Administrátor',
|
||||
user: 'Uživatel'
|
||||
},
|
||||
users: {
|
||||
title: 'Uživatelé',
|
||||
create: 'Vytvořit uživatele',
|
||||
enabled: 'Zapnuto',
|
||||
disabled: 'Vypnuto',
|
||||
deviceOwner: 'Vlastník zařízení',
|
||||
resetPassword: 'Obnovit heslo',
|
||||
delete: 'Smazat',
|
||||
deleteConfirm: 'Smazat tohoto uživatele a zrušit všechny jeho relace?',
|
||||
created: 'Uživatel vytvořen',
|
||||
deleted: 'Uživatel smazán',
|
||||
passwordUpdated: 'Heslo aktualizováno',
|
||||
loadFailed: 'Uživatele se nepodařilo načíst',
|
||||
saveFailed: 'Uživatele se nepodařilo uložit',
|
||||
deleteFailed: 'Uživatele se nepodařilo smazat',
|
||||
editPermissions: 'Oprávnění',
|
||||
mcpLossTitle: 'Odebrat přístup MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} ztratí přístup MCP. API klíč MCP sdílí celé zařízení, takže jeho kopie funguje, dokud klíč znovu nevygenerujete. Nové vygenerování odpojí všechny MCP klienty, dokud nedostanou nový klíč.',
|
||||
mcpLossRotate: 'Znovu vygenerovat API klíč MCP',
|
||||
mcpLossKeep:
|
||||
'Současný klíč zůstane platný a kdo má jeho kopii, může dál ovládat klávesnici a myš. Později jej můžete znovu vygenerovat v nastavení MCP.',
|
||||
mcpKeyRotated: 'API klíč MCP byl znovu vygenerován. MCP klienti potřebují nový klíč.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Oprávnění',
|
||||
editTitle: 'Upravit oprávnění',
|
||||
save: 'Uložit',
|
||||
saved: 'Uloženo',
|
||||
close: 'Zavřít',
|
||||
groupConsole: 'Konzole',
|
||||
groupTarget: 'Cílový počítač',
|
||||
groupLibrary: 'Knihovna médií',
|
||||
groupHost: 'Toto zařízení',
|
||||
groupServices: 'Služby',
|
||||
consoleView: 'Sledovat obrazovku',
|
||||
consoleControl: 'Klávesnice a myš',
|
||||
power: 'Napájení a probuzení',
|
||||
mediumAttach: 'Připojit médium',
|
||||
libraryRead: 'Procházet knihovnu',
|
||||
libraryWrite: 'Spravovat knihovnu',
|
||||
terminal: 'Terminál a skripty',
|
||||
device: 'Nastavení zařízení',
|
||||
network: 'Nastavení sítě',
|
||||
update: 'Aktualizace systému',
|
||||
mcp: 'Služba MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI asistent',
|
||||
effectConsoleView:
|
||||
'Zobrazuje obrazovku cílového počítače a nastavuje režim videa, rozlišení, snímkovou frekvenci a kvalitu, které sdílí všichni diváci.',
|
||||
effectConsoleControl: 'Posílá vstup z klávesnice a myši cílovému počítači.',
|
||||
effectPower:
|
||||
'Zapíná, vypíná a restartuje cílový počítač a spravuje seznam zařízení Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Připojuje a odpojuje média na cílovém počítači a vyměňuje s ním soubory přes sdílený oddíl.',
|
||||
effectLibraryRead: 'Vypisuje a stahuje obrazy a soubory v nich uložené.',
|
||||
effectLibraryWrite: 'Nahrává, vytváří a maže obrazy a mění soubory v nich uložené.',
|
||||
effectTerminal: 'Poskytuje root shell na tomto zařízení, bez webového sezení mezi tím.',
|
||||
effectDevice:
|
||||
'Umožňuje zapnout SSH (také root), restartovat zařízení, předat surový datový oddíl cílovému počítači a měnit nastavení OLED, HDMI, mDNS, paměti a swapu.',
|
||||
effectNetwork: 'Mění Wi-Fi, DNS a TLS, což může toto zařízení odpojit od sítě.',
|
||||
effectUpdate: 'Instaluje firmware a balíčky aplikací.',
|
||||
effectMcp:
|
||||
'Vydává API klíč platný pro celé zařízení, který ovládá klávesnici, myš a snímky obrazovky mimo webové sezení; kopie funguje i po odebrání oprávnění, pokud klíč znovu nevygenerujete, což se nabízí při odebrání oprávnění.',
|
||||
effectTailscale: 'Připojuje toto zařízení do VPN sítě.',
|
||||
effectAi:
|
||||
'Ovládá zařízení přes lokální rozhraní, které ostatní oprávnění neomezují, takže jde prakticky o plnou kontrolu nad zařízením.',
|
||||
fullControlWarning: 'Tato oprávnění představují plnou kontrolu nad zařízením:',
|
||||
emptyWarning: 'Tento účet nebude mít žádné funkce a bude se moci jen přihlásit.',
|
||||
impliedBy: 'Zahrnuto díky {{name}}',
|
||||
grantCount_one: '{{count}} oprávnění',
|
||||
grantCount: '{{count}} oprávnění',
|
||||
adminAll: 'Administrátoři mají všechna oprávnění',
|
||||
catalogFailed: 'Seznam oprávnění se nepodařilo načíst. Zavřete dialog a zkuste to znovu.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const da = {
|
||||
login: 'Log ind',
|
||||
changePassword: 'Skift adgangskode',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Diskbilleder og fildeling'
|
||||
},
|
||||
auth: {
|
||||
login: 'Log ind',
|
||||
@@ -39,7 +40,10 @@ const da = {
|
||||
change2: 'Adgangskode til weblogin',
|
||||
change3: 'Systemets root-adgangskode (SSH-loginadgangskode)',
|
||||
change4: 'For at nulstille adgangskoderne skal du holde BOOT-knappen på NanoKVM nede.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Nuværende adgangskode',
|
||||
passwordLength: 'Adgangskoden skal være mellem 8 og 72 tegn',
|
||||
invalidCurrentPassword: 'Den nuværende adgangskode er forkert'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const da = {
|
||||
verifyBtn: 'Bekræft'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Kun visning',
|
||||
readOnlyTip:
|
||||
'Denne konto kan ikke sende tastatur- eller musinput til målmaskinen, så konsollen viser kun skærmen.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Denne konto har ingen konsoladgang',
|
||||
description:
|
||||
'Den har fået andre rettigheder. Åbn en af de sider, den kan bruge, eller bed en administrator om konsoladgang.',
|
||||
nothing: 'Denne konto har endnu ingen rettigheder. Bed en administrator om at give nogle.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Skærm',
|
||||
@@ -213,33 +228,44 @@ const da = {
|
||||
disable: 'Deaktiver HID-kun tilstand'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Diskbilleder',
|
||||
loading: 'Kontrollerer...',
|
||||
empty: 'Ingen fundet',
|
||||
storage: {
|
||||
title: 'Diskbilleder og fildeling',
|
||||
empty:
|
||||
'Der er endnu ingen diskbilleder. Upload en ISO eller IMG, eller kopier en over på disken fra målcomputeren.',
|
||||
notAttached: 'Intet monteret',
|
||||
attached: 'Monteret: {{name}}',
|
||||
eject: 'Skub ud',
|
||||
mount: 'Montér',
|
||||
mountMode: 'Monteringstilstand',
|
||||
mountFailed: 'Montering af diskbillede mislykkedes',
|
||||
mountDesc:
|
||||
'På nogle systemer kan det være nødvendigt at skubbe den virtuelle disk ud på fjerncomputeren før du kan montere diskbilledet.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Afmontering mislykkedes',
|
||||
unmountDesc:
|
||||
'På nogle systemer skal du manuelt skubbe ud fra fjernværten, før du afmonterer billedet.',
|
||||
refresh: 'Opdater billedlisten',
|
||||
attention: 'Opmærksomhed påkrævet',
|
||||
deleteConfirm: 'Er du sikker på, at du vil slette dette billede?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller',
|
||||
mountModeDisk: 'Masselager',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Åbn filhåndtering',
|
||||
refresh: 'Opdater',
|
||||
delete: 'Slet',
|
||||
deleteTitle: 'Slet medie',
|
||||
deleteConfirm: 'Vil du slette {{name}}? Filerne i den går tabt.',
|
||||
downloadImage: 'Download diskbilledet',
|
||||
typePartition: 'Delte filer',
|
||||
volumeLabel: 'Flytbar disk',
|
||||
groups: {
|
||||
share: 'Fildeling',
|
||||
images: 'Diskbilleder'
|
||||
},
|
||||
failed: 'Handlingen mislykkedes',
|
||||
hidOnly: 'Kun HID-tilstand: den virtuelle disk er ikke tilgængelig',
|
||||
disabled: 'Den virtuelle disk er slået fra (Indstillinger - Enhed)',
|
||||
force: {
|
||||
title: 'Tag mediet tilbage med magt?',
|
||||
desc: 'Målcomputeren bruger stadig dette medie. At tage det tilbage opregner USB-enheden igen og kan miste alt, hvad den endnu ikke har skrevet til disken.',
|
||||
menuHint:
|
||||
'Målcomputeren bruger dette medie. Åbn filhåndteringen for at tage det tilbage med magt.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Sådan uploader du',
|
||||
usb1: 'Forbind din NanoKVM til din computer via USB.',
|
||||
usb2: 'Sørg for, at den virtuelle disk er monteret (Indstillinger -> Virtuel disk).',
|
||||
usb2: 'Montér de delte filer i masselagringstilstand på denne side.',
|
||||
usb3: 'Åben den virtuelle disk på din computer og kopier diskbilledet til roden af den virtuelle disk.',
|
||||
usb4: 'Skub disken ud, når kopieringen er færdig: billedet vises derefter på listen.',
|
||||
scp1: 'Kontroller at din NanoKVM og din computer er på samme lokale netværk.',
|
||||
scp2: 'Åben en terminal på din computer og brug SCP-kommandoen for at uploade diskbilledet til /data mappen på din NanoKVM.',
|
||||
scp3: 'Eksempel: scp sti-til-dit-diskbillede root@din-nanokvm-ip:/data',
|
||||
@@ -249,7 +275,69 @@ const da = {
|
||||
tf3: 'Indsæt microSD-kortet i en kortlæser og tilslut den til en computer.',
|
||||
tf4: 'Kopier diskbilledet til /data mappen på microSD-kortet.',
|
||||
tf5: 'Skub microSD-kortet ud og indsæt microSD-kortet i din NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Arbejder på {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Slå den virtuelle disk til?',
|
||||
desc: 'For at montere et medie kræves den virtuelle disk, som er slået fra lige nu. Slå den til? Målcomputerens tastatur og mus afbrydes et øjeblik.',
|
||||
menuHint: 'Den virtuelle disk er slået fra. Slå den til under Indstillinger - Enhed.',
|
||||
askDevice:
|
||||
'Den virtuelle disk er slået fra. En konto med enhedstilladelse skal aktivere den først.'
|
||||
},
|
||||
add: {
|
||||
title: 'Tilføj medie',
|
||||
upload: 'Upload en ISO eller IMG',
|
||||
fromUrl: 'Download fra en URL',
|
||||
url: 'URL til diskbilledet',
|
||||
urlRequired: 'Indtast en URL',
|
||||
onlyImages: 'Kun .iso- og .img-filer kan monteres',
|
||||
dropHint: 'Træk en ISO eller IMG hertil, eller klik for at vælge en fil',
|
||||
cdromOnlyIso: 'En CD-ROM kan kun indeholde et ISO 9660-diskbillede',
|
||||
uploadFailed: 'Upload mislykkedes',
|
||||
downloadFailed: 'Download mislykkedes',
|
||||
uploading: 'Uploader {{name}}',
|
||||
downloading: 'Downloader {{name}}',
|
||||
done: '{{name}} er klar',
|
||||
checksumFailed: 'Billedet stemmer ikke overens med SHA-256-kontrolsummen.',
|
||||
blocked: 'Målcomputeren bruger disken',
|
||||
copyHint:
|
||||
'Du kan også kopiere et diskbillede til datapartitionen via SCP eller fra målcomputeren.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Vælg et medie for at se filerne på det.',
|
||||
reasonAttached: 'Dette medie er koblet til målcomputeren.',
|
||||
reasonAttachedHint:
|
||||
'Skub det ud først: målcomputeren skal frigive disken, før dens filer kan læses.',
|
||||
reasonNotBrowsable:
|
||||
'Dette medie indeholder et filsystem, som filhåndteringen ikke kan åbne.',
|
||||
reasonNoLoop: 'Denne enhed kan ikke montere diskbilleder (ingen loop-understøttelse).',
|
||||
reasonISOImage:
|
||||
'Et ISO 9660-diskbillede kan ikke gennemses her. Det kan stadig monteres på målcomputeren.',
|
||||
empty: 'Denne mappe er tom',
|
||||
noMatch: 'Ingen filer matcher søgningen.',
|
||||
free: '{{size}} ledige',
|
||||
search: 'Søg i denne mappe',
|
||||
sort: {
|
||||
name: 'Navn',
|
||||
size: 'Størrelse',
|
||||
type: 'Type',
|
||||
asc: 'Stigende',
|
||||
desc: 'Faldende'
|
||||
},
|
||||
upload: 'Upload en fil til mediet',
|
||||
deleteFolderTitle: 'Slet mappe',
|
||||
deleteFolderConfirm: 'Slet mappen {{name}} og alt dens indhold? Dette kan ikke fortrydes.',
|
||||
uploading: 'Uploader…',
|
||||
uploadFailed: 'Upload mislykkedes',
|
||||
download: 'Download',
|
||||
delete: 'Slet',
|
||||
deleteTitle: 'Slet fil',
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?'
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nej'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -291,21 +379,11 @@ const da = {
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'Billedhenter',
|
||||
input: 'Indtast venligst et fjernbillede URL',
|
||||
ok: 'OK',
|
||||
disabled: '/data partitionen er RO, så vi kan ikke downloade billedet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Slip filen her, eller klik for at vælge',
|
||||
inputfile: 'Indtast venligst billedfilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
disabled:
|
||||
'Datapartitionen er skrivebeskyttet, mens målcomputeren bruger den: nye diskbilleder kan ikke skrives, men eksisterende filer kan stadig downloades.',
|
||||
sha256: 'SHA-256 (valgfri)',
|
||||
sha256Placeholder: 'Indtast en SHA-256-kontrolsum på 64 tegn',
|
||||
invalidSHA256: 'SHA-256 skal være en hexadecimal streng på 64 tegn',
|
||||
failed: 'Download mislykkedes',
|
||||
success: 'Download gennemført',
|
||||
checksumFailed: 'Download mislykkedes: SHA-256-verifikation mislykkedes',
|
||||
cancel: 'Annuller',
|
||||
cancelFailed: 'Kunne ikke annullere download'
|
||||
},
|
||||
@@ -454,16 +532,18 @@ const da = {
|
||||
'Stop med at emulere virtuelle enheder, og behold kun grundlæggende HID kontrol',
|
||||
disk: 'Virtuel disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuelt netværk',
|
||||
networkDesc: 'Monter det virtuelle netværkskort på den eksterne vært',
|
||||
reboot: 'Genstart',
|
||||
rebootDesc: 'Er du sikker på, at du vil genstarte NanoKVM?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller'
|
||||
cancelBtn: 'Annuller',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Opmærksomhed påkrævet',
|
||||
updateFailed: 'Kunne ikke opdatere den virtuelle enhed'
|
||||
},
|
||||
network: {
|
||||
title: 'Netværk',
|
||||
@@ -602,7 +682,87 @@ const da = {
|
||||
logoutBtn: 'Log ud',
|
||||
logoutDesc: 'Er du sikker på, at du vil logge ud?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Annuller'
|
||||
cancelBtn: 'Annuller',
|
||||
role: 'Rolle',
|
||||
roles: {
|
||||
admin: 'Administrator',
|
||||
user: 'Bruger'
|
||||
},
|
||||
users: {
|
||||
title: 'Brugere',
|
||||
create: 'Opret bruger',
|
||||
enabled: 'Aktiveret',
|
||||
disabled: 'Deaktiveret',
|
||||
deviceOwner: 'Enhedens ejer',
|
||||
resetPassword: 'Nulstil adgangskode',
|
||||
delete: 'Slet',
|
||||
deleteConfirm: 'Vil du slette denne bruger og tilbagekalde alle dens sessioner?',
|
||||
created: 'Bruger oprettet',
|
||||
deleted: 'Bruger slettet',
|
||||
passwordUpdated: 'Adgangskode opdateret',
|
||||
loadFailed: 'Kunne ikke indlæse brugere',
|
||||
saveFailed: 'Kunne ikke gemme brugeren',
|
||||
deleteFailed: 'Kunne ikke slette brugeren',
|
||||
editPermissions: 'Tilladelser',
|
||||
mcpLossTitle: 'Fjern MCP-adgang',
|
||||
mcpLossDesc:
|
||||
'{{name}} mister MCP-adgang. MCP-API-nøglen deles af hele enheden, så en kopi virker, indtil nøglen gendannes. Gendannelse afbryder alle MCP-klienter, indtil de får den nye nøgle.',
|
||||
mcpLossRotate: 'Gendan MCP-API-nøglen',
|
||||
mcpLossKeep:
|
||||
'Den nuværende nøgle forbliver gyldig, og den, der har en kopi, kan stadig styre tastatur og mus. Du kan gendanne den senere i MCP-indstillingerne.',
|
||||
mcpKeyRotated: 'MCP-API-nøglen er gendannet. MCP-klienter skal bruge den nye nøgle.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Rettigheder',
|
||||
editTitle: 'Rediger rettigheder',
|
||||
save: 'Gem',
|
||||
saved: 'Gemt',
|
||||
close: 'Luk',
|
||||
groupConsole: 'Konsol',
|
||||
groupTarget: 'Målmaskine',
|
||||
groupLibrary: 'Mediebibliotek',
|
||||
groupHost: 'Denne enhed',
|
||||
groupServices: 'Tjenester',
|
||||
consoleView: 'Se skærmen',
|
||||
consoleControl: 'Tastatur og mus',
|
||||
power: 'Strøm og vækning',
|
||||
mediumAttach: 'Tilslut et medie',
|
||||
libraryRead: 'Gennemse biblioteket',
|
||||
libraryWrite: 'Administrer biblioteket',
|
||||
terminal: 'Terminal og scripts',
|
||||
device: 'Enhedsindstillinger',
|
||||
network: 'Netværksindstillinger',
|
||||
update: 'Systemopdatering',
|
||||
mcp: 'MCP-tjeneste',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI-assistent',
|
||||
effectConsoleView:
|
||||
'Viser målmaskinens skærm og angiver videotilstand, opløsning, billedhastighed og kvalitet, som alle seere deler.',
|
||||
effectConsoleControl: 'Sender tastatur- og musinput til målmaskinen.',
|
||||
effectPower:
|
||||
'Tænder, slukker og genstarter målmaskinen og administrerer listen over Wake-on-LAN-enheder.',
|
||||
effectMediumAttach:
|
||||
'Monterer og afmonterer medier på målmaskinen og udveksler filer med den via den delte partition.',
|
||||
effectLibraryRead: 'Viser og henter images og filerne i dem.',
|
||||
effectLibraryWrite: 'Uploader, opretter og sletter images og ændrer filerne i dem.',
|
||||
effectTerminal: 'Giver et root-shell på denne enhed uden om websessionen.',
|
||||
effectDevice:
|
||||
'Gør det muligt at slå SSH til (også root), genstarte enheden, give den rå datapartition til målmaskinen og ændre OLED, HDMI, mDNS, hukommelse og swap.',
|
||||
effectNetwork: 'Ændrer Wi-Fi, DNS og TLS, hvilket kan tage denne enhed af netværket.',
|
||||
effectUpdate: 'Installerer firmware og programpakker.',
|
||||
effectMcp:
|
||||
'Udsteder en API-nøgle for hele enheden, der styrer tastatur, mus og skærmbilleder uden for websessionen; en kopi virker stadig, efter rettigheden er fjernet, medmindre nøglen gendannes, hvilket tilbydes, når rettigheden fjernes.',
|
||||
effectTailscale: 'Forbinder denne enhed til et VPN-mesh.',
|
||||
effectAi:
|
||||
'Styrer enheden via en lokal grænseflade, som de øvrige rettigheder ikke begrænser, så det svarer til fuld kontrol over enheden.',
|
||||
fullControlWarning: 'Disse rettigheder svarer til fuld kontrol over enheden:',
|
||||
emptyWarning: 'Denne konto har ingen funktioner og kan kun logge ind.',
|
||||
impliedBy: 'Inkluderet af {{name}}',
|
||||
grantCount_one: '{{count}} rettighed',
|
||||
grantCount: '{{count}} rettigheder',
|
||||
adminAll: 'Administratorer har alle rettigheder',
|
||||
catalogFailed: 'Rettighedslisten kunne ikke indlæses. Luk dialogen, og prøv igen.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const de = {
|
||||
login: 'Anmelden',
|
||||
changePassword: 'Passwort ändern',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Abbilder & Dateifreigabe'
|
||||
},
|
||||
auth: {
|
||||
login: 'Anmelden',
|
||||
@@ -40,7 +41,10 @@ const de = {
|
||||
change3: 'System root Passwort (SSH Anmelde-Passwort)',
|
||||
change4:
|
||||
'Um die Passwörter zurückzusetzen, drücken und halten Sie den BOOT Knopf auf dem NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Aktuelles Passwort',
|
||||
passwordLength: 'Das Passwort muss zwischen 8 und 72 Zeichen lang sein',
|
||||
invalidCurrentPassword: 'Das aktuelle Passwort ist falsch'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -60,6 +64,18 @@ const de = {
|
||||
verifyBtn: 'Überprüfen'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Nur Ansehen',
|
||||
readOnlyTip:
|
||||
'Dieses Konto kann keine Tastatur- oder Mauseingaben an den Zielrechner senden, daher zeigt die Konsole nur das Bild.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Dieses Konto hat keinen Konsolenzugriff',
|
||||
description:
|
||||
'Ihm wurden andere Fähigkeiten erteilt. Öffnen Sie eine der Seiten, die es nutzen darf, oder bitten Sie einen Administrator um Konsolenzugriff.',
|
||||
nothing:
|
||||
'Dieses Konto hat noch keine Berechtigungen. Bitten Sie einen Administrator, welche zu erteilen.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Bildschirm',
|
||||
@@ -218,33 +234,44 @@ const de = {
|
||||
disable: 'HID-Only Modus deaktivieren'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Bilder',
|
||||
loading: 'Lädt...',
|
||||
empty: 'Nichts gefunden',
|
||||
storage: {
|
||||
title: 'Abbilder & Dateifreigabe',
|
||||
empty:
|
||||
'Noch keine Abbilder. Laden Sie eine ISO oder IMG hoch oder kopieren Sie eine vom Zielrechner auf die Festplatte.',
|
||||
notAttached: 'Nichts eingebunden',
|
||||
attached: 'Eingebunden: {{name}}',
|
||||
eject: 'Auswerfen',
|
||||
mount: 'Einbinden',
|
||||
mountMode: 'Mount-Modus',
|
||||
mountFailed: 'Einbinden fehlgeschlagen',
|
||||
mountDesc:
|
||||
'In einigen Systemen ist es notwendig, die virtuelle Festplatte auf dem entfernten Host auszuwerfen, bevor das Image eingebunden werden kann.',
|
||||
diskOnWarn:
|
||||
'Beim Einbinden des Images wird das virtuelle Laufwerk ausgeschaltet, sodass der Computer nicht mehr auf /data zugreifen kann. Fortfahren?',
|
||||
diskOffWarn:
|
||||
'Das virtuelle Laufwerk ist ausgeschaltet und muss zum Einbinden des Images aktiviert werden. Die USB-Geräte am Computer werden neu erkannt. Fortfahren?',
|
||||
diskShared:
|
||||
'Das virtuelle Laufwerk ist aktiv, daher ist /data hier schreibgeschützt. Deaktivieren Sie es, um Images zu löschen oder hochzuladen.',
|
||||
unmountFailed: 'Das Aufheben der Bereitstellung ist fehlgeschlagen',
|
||||
unmountDesc:
|
||||
'Auf einigen Systemen müssen Sie das Image manuell vom Remote-Host auswerfen, bevor Sie die Bereitstellung aufheben.',
|
||||
refresh: 'Bilder aktualisieren',
|
||||
attention: 'Achtung',
|
||||
deleteConfirm: 'Sind Sie sicher, dass Sie dieses Bild löschen möchten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein',
|
||||
mountModeDisk: 'Massenspeicher',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Dateimanager öffnen',
|
||||
refresh: 'Aktualisieren',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: 'Medium löschen',
|
||||
deleteConfirm: '{{name}} löschen? Die darin enthaltenen Dateien gehen verloren.',
|
||||
downloadImage: 'Abbild herunterladen',
|
||||
typePartition: 'Freigegebene Dateien',
|
||||
volumeLabel: 'Wechseldatenträger',
|
||||
groups: {
|
||||
share: 'Dateifreigabe',
|
||||
images: 'Abbilder'
|
||||
},
|
||||
failed: 'Vorgang fehlgeschlagen',
|
||||
hidOnly: 'HID-Modus: Das virtuelle Laufwerk ist nicht verfügbar',
|
||||
disabled: 'Das virtuelle Laufwerk ist deaktiviert (Einstellungen - Gerät)',
|
||||
force: {
|
||||
title: 'Medium mit Gewalt zurückholen?',
|
||||
desc: 'Der Zielrechner verwendet dieses Medium noch. Beim Zurückholen wird das USB-Gerät neu aufgelistet, und alles, was noch nicht auf den Datenträger geschrieben wurde, kann verloren gehen.',
|
||||
menuHint:
|
||||
'Der Zielrechner verwendet dieses Medium. Öffnen Sie den Dateimanager, um es mit Gewalt zurückzuholen.'
|
||||
},
|
||||
tips: {
|
||||
title: 'So laden Sie Dateien hoch',
|
||||
usb1: 'Verbinden Sie den NanoKVM über USB mit Ihrem Computer.',
|
||||
usb2: 'Stellen Sie sicher, dass die virtuelle Festplatte eingebunden ist (Einstellungen – Virtuelle Festplatte).',
|
||||
usb2: 'Binden Sie auf dieser Seite die freigegebenen Dateien im Massenspeicher-Modus ein.',
|
||||
usb3: 'Öffnen Sie die virtuelle Festplatte auf Ihrem Computer und kopieren Sie die Image-Datei in das Stammverzeichnis der virtuellen Festplatte.',
|
||||
usb4: 'Werfen Sie das Laufwerk nach dem Kopieren aus: Danach erscheint das Image in der Liste.',
|
||||
scp1: 'Stellen Sie sicher, dass sich der NanoKVM und Ihr Computer im selben lokalen Netzwerk befinden.',
|
||||
scp2: 'Öffnen Sie ein Terminal auf Ihrem Computer und verwenden Sie den SCP-Befehl, um die Image-Datei in das Verzeichnis /data auf dem NanoKVM hochzuladen.',
|
||||
scp3: 'Beispiel: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -254,14 +281,79 @@ const de = {
|
||||
tf3: 'Stecken Sie die TF-Karte in einen Kartenleser und verbinden Sie diesen mit Ihrem Computer.',
|
||||
tf4: 'Kopieren Sie die Image-Datei in das Verzeichnis /data auf der TF-Karte.',
|
||||
tf5: 'Setzen Sie die TF-Karte wieder in den NanoKVM ein.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '{{name}} wird bearbeitet'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Virtuelles Laufwerk aktivieren?',
|
||||
desc: 'Zum Einbinden eines Mediums wird das virtuelle Laufwerk benötigt, das derzeit ausgeschaltet ist. Jetzt aktivieren? Tastatur und Maus des Zielrechners werden kurz getrennt.',
|
||||
menuHint:
|
||||
'Das virtuelle Laufwerk ist deaktiviert. Aktivieren Sie es unter Einstellungen - Gerät.',
|
||||
askDevice:
|
||||
'Das virtuelle Laufwerk ist ausgeschaltet. Ein Konto mit der Geräte-Berechtigung muss es zuerst einschalten.'
|
||||
},
|
||||
add: {
|
||||
title: 'Medium hinzufügen',
|
||||
upload: 'ISO oder IMG hochladen',
|
||||
fromUrl: 'Von einer URL herunterladen',
|
||||
url: 'Abbild-URL',
|
||||
urlRequired: 'Bitte geben Sie eine URL ein',
|
||||
onlyImages: 'Nur .iso- und .img-Dateien können eingebunden werden',
|
||||
dropHint: 'Ziehen Sie eine ISO oder IMG hierher oder klicken Sie, um eine Datei zu wählen',
|
||||
cdromOnlyIso: 'Ein CD-ROM kann nur ein ISO-9660-Abbild enthalten',
|
||||
uploadFailed: 'Hochladen fehlgeschlagen',
|
||||
downloadFailed: 'Herunterladen fehlgeschlagen',
|
||||
uploading: '{{name}} wird hochgeladen',
|
||||
downloading: '{{name}} wird heruntergeladen',
|
||||
done: '{{name}} ist bereit',
|
||||
checksumFailed: 'Das Image stimmt nicht mit der SHA-256-Prüfsumme überein.',
|
||||
blocked: 'Der Zielrechner verwendet die Festplatte',
|
||||
copyHint:
|
||||
'Sie können ein Abbild auch per SCP auf die Datenpartition kopieren oder vom Zielrechner aus.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Wählen Sie ein Medium, um die darin enthaltenen Dateien zu durchsuchen.',
|
||||
reasonAttached: 'Dieses Medium ist mit dem Zielrechner verbunden.',
|
||||
reasonAttachedHint:
|
||||
'Werfen Sie es zuerst aus: Der Zielrechner muss die Festplatte freigeben, bevor ihre Dateien gelesen werden können.',
|
||||
reasonNotBrowsable:
|
||||
'Dieses Medium enthält ein Dateisystem, das der Dateimanager nicht öffnen kann.',
|
||||
reasonNoLoop:
|
||||
'Dieses Gerät kann keine Datenträgerabbilder einbinden (keine Loop-Unterstützung).',
|
||||
reasonISOImage:
|
||||
'Ein ISO-9660-Abbild lässt sich hier nicht durchsuchen. Es kann weiterhin am Zielrechner eingebunden werden.',
|
||||
empty: 'Dieser Ordner ist leer',
|
||||
noMatch: 'Keine Datei entspricht der Suche.',
|
||||
free: '{{size}} frei',
|
||||
search: 'Diesen Ordner durchsuchen',
|
||||
sort: {
|
||||
name: 'Name',
|
||||
size: 'Größe',
|
||||
type: 'Typ',
|
||||
asc: 'Aufsteigend',
|
||||
desc: 'Absteigend'
|
||||
},
|
||||
upload: 'Eine Datei auf das Medium hochladen',
|
||||
deleteFolderTitle: 'Ordner löschen',
|
||||
deleteFolderConfirm:
|
||||
'Den Ordner {{name}} mit seinem gesamten Inhalt löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
uploading: 'Wird hochgeladen…',
|
||||
uploadFailed: 'Hochladen fehlgeschlagen',
|
||||
download: 'Herunterladen',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: 'Datei löschen',
|
||||
deleteConfirm: '{{name}} vom Medium löschen?'
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nein'
|
||||
},
|
||||
script: {
|
||||
title: 'Skripte',
|
||||
upload: 'Hochladen',
|
||||
run: 'Ausführen',
|
||||
runBackground: 'Im Hintergrund ausführen',
|
||||
runFailed: '',
|
||||
runFailed: 'Ausführung fehlgeschlagen',
|
||||
attention: 'Achtung',
|
||||
delDesc: 'Möchten Sie diese Datei wirklich löschen?',
|
||||
confirm: 'Ja',
|
||||
@@ -296,23 +388,11 @@ const de = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Systemabbild Downloader',
|
||||
input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein',
|
||||
ok: 'Ok',
|
||||
disabled:
|
||||
'/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
|
||||
diskShared:
|
||||
'Schalten Sie zuerst das virtuelle Laufwerk aus und verwenden Sie dann den Image-Download.',
|
||||
diskOff: 'Virtuelles Laufwerk ausschalten',
|
||||
uploadbox: 'Datei hier ablegen oder klicken zum Auswählen',
|
||||
inputfile: 'Bitte geben Sie die Datei für das Systemabbild an',
|
||||
NoISO: 'Keine ISO',
|
||||
'Die Datenpartition ist schreibgeschützt, solange der Zielrechner sie verwendet: Neue Abbilder lassen sich nicht ablegen, vorhandene Dateien aber weiterhin herunterladen.',
|
||||
sha256: 'SHA-256 (optional)',
|
||||
sha256Placeholder: 'Geben Sie eine 64-stellige SHA-256-Prüfsumme ein',
|
||||
invalidSHA256: 'SHA-256 muss eine 64-stellige Hexadezimalzeichenfolge sein',
|
||||
failed: 'Download fehlgeschlagen',
|
||||
success: 'Download erfolgreich',
|
||||
checksumFailed: 'Download fehlgeschlagen: SHA-256-Prüfung fehlgeschlagen',
|
||||
cancel: 'Abbrechen',
|
||||
cancelFailed: 'Download konnte nicht abgebrochen werden'
|
||||
},
|
||||
@@ -463,17 +543,19 @@ const de = {
|
||||
'Hören Sie auf, virtuelle Geräte zu emulieren, und behalten Sie nur die grundlegende HID-Steuerung bei',
|
||||
disk: 'Virtuelle Festplatte',
|
||||
diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host',
|
||||
diskLocked: 'Aus, solange ein Image eingebunden ist (Einschalten bricht die Einbindung ab)',
|
||||
diskCancelMount:
|
||||
'Das Aktivieren des virtuellen Laufwerks bricht das eingebundene Image ab und unterbricht einen laufenden Image-Download oder -Upload. Fortfahren?',
|
||||
diskBreakDownload:
|
||||
'Das Aktivieren des virtuellen Laufwerks unterbricht den Image-Download. Fortfahren?',
|
||||
network: 'Virtuelles Netzwerk',
|
||||
networkDesc: 'Binde die virtuelle Netzwerkkarte an den entfernten Host',
|
||||
reboot: 'Neustarten',
|
||||
rebootDesc: 'Sind Sie sicher dass Sie NanoKVM neustarten möchten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
cancelBtn: 'Nein',
|
||||
diskLocked: 'Aus, solange ein Image eingebunden ist (Einschalten bricht die Einbindung ab)',
|
||||
diskCancelMount:
|
||||
'Das Aktivieren des virtuellen Laufwerks bricht das eingebundene Image ab und unterbricht einen laufenden Image-Download oder -Upload. Fortfahren?',
|
||||
diskBreakDownload:
|
||||
'Das Aktivieren des virtuellen Laufwerks unterbricht den Image-Download. Fortfahren?',
|
||||
attention: 'Achtung',
|
||||
updateFailed: 'Aktualisieren des virtuellen Geräts fehlgeschlagen'
|
||||
},
|
||||
network: {
|
||||
title: 'Netzwerk',
|
||||
@@ -614,7 +696,91 @@ const de = {
|
||||
logoutBtn: 'Abmelden',
|
||||
logoutDesc: 'Möchten Sie sich wirklich abmelden?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nein'
|
||||
cancelBtn: 'Nein',
|
||||
role: 'Rolle',
|
||||
roles: {
|
||||
admin: 'Administrator',
|
||||
user: 'Benutzer'
|
||||
},
|
||||
users: {
|
||||
title: 'Benutzer',
|
||||
create: 'Benutzer erstellen',
|
||||
enabled: 'Aktiviert',
|
||||
disabled: 'Deaktiviert',
|
||||
deviceOwner: 'Geräteeigentümer',
|
||||
resetPassword: 'Passwort zurücksetzen',
|
||||
delete: 'Löschen',
|
||||
deleteConfirm: 'Diesen Benutzer löschen und alle seine Sitzungen widerrufen?',
|
||||
created: 'Benutzer erstellt',
|
||||
deleted: 'Benutzer gelöscht',
|
||||
passwordUpdated: 'Passwort aktualisiert',
|
||||
loadFailed: 'Benutzer konnten nicht geladen werden',
|
||||
saveFailed: 'Benutzer konnte nicht gespeichert werden',
|
||||
deleteFailed: 'Benutzer konnte nicht gelöscht werden',
|
||||
editPermissions: 'Berechtigungen',
|
||||
mcpLossTitle: 'MCP-Zugriff entziehen',
|
||||
mcpLossDesc:
|
||||
'{{name}} verliert den MCP-Zugriff. Der MCP-API-Schlüssel gilt für das ganze Gerät, eine Kopie bleibt daher gültig, bis der Schlüssel neu erzeugt wird. Das Neuerzeugen trennt alle MCP-Clients, bis sie den neuen Schlüssel erhalten.',
|
||||
mcpLossRotate: 'MCP-API-Schlüssel neu erzeugen',
|
||||
mcpLossKeep:
|
||||
'Der aktuelle Schlüssel bleibt gültig, und wer eine Kopie hat, kann weiterhin Tastatur und Maus steuern. Sie können ihn später in den MCP-Einstellungen neu erzeugen.',
|
||||
mcpKeyRotated: 'MCP-API-Schlüssel neu erzeugt. MCP-Clients benötigen den neuen Schlüssel.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Berechtigungen',
|
||||
editTitle: 'Berechtigungen bearbeiten',
|
||||
save: 'Speichern',
|
||||
saved: 'Gespeichert',
|
||||
close: 'Schließen',
|
||||
groupConsole: 'Konsole',
|
||||
groupTarget: 'Zielrechner',
|
||||
groupLibrary: 'Medienbibliothek',
|
||||
groupHost: 'Dieses Gerät',
|
||||
groupServices: 'Dienste',
|
||||
consoleView: 'Bildschirm ansehen',
|
||||
consoleControl: 'Tastatur und Maus',
|
||||
power: 'Energie und Aufwecken',
|
||||
mediumAttach: 'Medium einbinden',
|
||||
libraryRead: 'Medienbibliothek durchsuchen',
|
||||
libraryWrite: 'Medienbibliothek verwalten',
|
||||
terminal: 'Terminal und Skripte',
|
||||
device: 'Geräteeinstellungen',
|
||||
network: 'Netzwerkeinstellungen',
|
||||
update: 'Systemaktualisierung',
|
||||
mcp: 'MCP-Dienst',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'KI-Assistent',
|
||||
effectConsoleView:
|
||||
'Zeigt den Zielbildschirm und legt Videomodus, Auflösung, Bildrate und Qualität fest, die alle Betrachter gemeinsam nutzen.',
|
||||
effectConsoleControl: 'Sendet Tastatur- und Mauseingaben an den Zielrechner.',
|
||||
effectPower:
|
||||
'Schaltet den Zielrechner ein, aus und startet ihn neu und verwaltet die Wake-on-LAN-Geräteliste.',
|
||||
effectMediumAttach:
|
||||
'Bindet Medien am Zielrechner ein und aus und tauscht Dateien über die geteilte Partition mit ihm aus.',
|
||||
effectLibraryRead: 'Listet und lädt Abbilder und die darin enthaltenen Dateien herunter.',
|
||||
effectLibraryWrite:
|
||||
'Lädt Abbilder hoch, erstellt und löscht sie und ändert die darin enthaltenen Dateien.',
|
||||
effectTerminal:
|
||||
'Gewährt eine Root-Shell auf diesem Gerät, ohne den Umweg über die Websitzung.',
|
||||
effectDevice:
|
||||
'Ermöglicht SSH (ebenfalls root), startet das Gerät neu, übergibt die rohe Datenpartition an den Zielrechner und ändert OLED, HDMI, mDNS, Arbeitsspeicher und Swap.',
|
||||
effectNetwork: 'Ändert WLAN, DNS und TLS, was dieses Gerät vom Netz trennen kann.',
|
||||
effectUpdate: 'Installiert Firmware und Anwendungspakete.',
|
||||
effectMcp:
|
||||
'Stellt einen geräteweiten API-Schlüssel aus, der Tastatur, Maus und Screenshots außerhalb der Websitzung steuert; eine Kopie bleibt nach dem Entzug gültig, sofern der Schlüssel nicht neu erzeugt wird – dies wird beim Entfernen der Berechtigung angeboten.',
|
||||
effectTailscale: 'Bildet dieses Gerät in ein VPN-Mesh ein.',
|
||||
effectAi:
|
||||
'Steuert das Gerät über eine lokale Schnittstelle, die die übrigen Berechtigungen nicht einschränken; das entspricht praktisch der vollständigen Gerätekontrolle.',
|
||||
fullControlWarning:
|
||||
'Diese Berechtigungen laufen auf die vollständige Gerätekontrolle hinaus:',
|
||||
emptyWarning: 'Dieses Konto hat keinerlei Funktionen und kann sich nur anmelden.',
|
||||
impliedBy: 'Enthalten durch {{name}}',
|
||||
grantCount_one: '{{count}} Berechtigung',
|
||||
grantCount: '{{count}} Berechtigungen',
|
||||
adminAll: 'Administratoren besitzen alle Berechtigungen',
|
||||
catalogFailed:
|
||||
'Die Berechtigungsliste konnte nicht geladen werden. Schließen Sie diesen Dialog und versuchen Sie es erneut.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -4,6 +4,7 @@ const en = {
|
||||
desktop: 'Remote Desktop',
|
||||
login: 'Login',
|
||||
changePassword: 'Change Password',
|
||||
storage: 'Images & File sharing',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
@@ -60,6 +61,18 @@ const en = {
|
||||
verifyBtn: 'Verify'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'View Only',
|
||||
readOnlyTip:
|
||||
'This account cannot send keyboard or mouse input to the target machine, so the console only shows the screen.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'This account has no console access',
|
||||
description:
|
||||
'It was granted other capabilities. Open one of the pages it can use, or ask an administrator for console access.',
|
||||
nothing: 'This account has no feature permissions yet. Ask an administrator to grant some.'
|
||||
},
|
||||
|
||||
screen: {
|
||||
scale: 'Scale',
|
||||
title: 'Screen',
|
||||
@@ -214,33 +227,44 @@ const en = {
|
||||
disable: 'Disable HID-Only mode'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Images',
|
||||
loading: 'Loading...',
|
||||
empty: 'Nothing Found',
|
||||
storage: {
|
||||
title: 'Images & File sharing',
|
||||
empty:
|
||||
'No images yet. Upload an ISO or IMG, or copy one onto the disk from the target machine.',
|
||||
notAttached: 'Nothing mounted',
|
||||
attached: 'Mounted: {{name}}',
|
||||
eject: 'Eject',
|
||||
mount: 'Mount',
|
||||
mountMode: 'Mount mode',
|
||||
mountFailed: 'Mount failed',
|
||||
mountDesc:
|
||||
'On some systems, you need to eject the virtual disk from the remote host before mounting the image.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Unmount failed',
|
||||
unmountDesc:
|
||||
'On some systems, you need to manually eject from the remote host before unmounting the image.',
|
||||
refresh: 'Refresh the image list',
|
||||
attention: 'Attention',
|
||||
deleteConfirm: 'Are you sure you want to delete this image?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No',
|
||||
mountModeDisk: 'Mass storage',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Open file manager',
|
||||
refresh: 'Refresh',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete medium',
|
||||
deleteConfirm: 'Delete {{name}}? The files inside it are lost.',
|
||||
downloadImage: 'Download the image',
|
||||
typePartition: 'Shared files',
|
||||
volumeLabel: 'Removable disk',
|
||||
groups: {
|
||||
share: 'File sharing',
|
||||
images: 'Images'
|
||||
},
|
||||
failed: 'Operation failed',
|
||||
hidOnly: 'HID-only mode: the virtual disk is unavailable',
|
||||
disabled: 'The virtual disk is disabled (Settings - Device)',
|
||||
force: {
|
||||
title: 'Take the medium back by force?',
|
||||
desc: 'The target machine is still using this medium. Taking it back re-enumerates the USB device and can lose whatever it has not written to disk yet.',
|
||||
menuHint:
|
||||
'The target machine is using this medium. Open the file manager to take it back by force.'
|
||||
},
|
||||
tips: {
|
||||
title: 'How to upload',
|
||||
usb1: 'Connect the NanoKVM to your computer via USB.',
|
||||
usb2: 'Ensure that the virtual disk is mounted (Settings - Virtual Disk).',
|
||||
usb2: 'On this page, mount the shared files in mass storage mode.',
|
||||
usb3: 'Open the virtual disk on your computer and copy the image file to the root directory of the virtual disk.',
|
||||
usb4: 'Eject the disk when the copy is done: the image then shows up in the list.',
|
||||
scp1: 'Make sure the NanoKVM and your computer are on the same local network.',
|
||||
scp2: 'Open a terminal on your computer and use the SCP command to upload the image file to the /data directory on the NanoKVM.',
|
||||
scp3: 'Example: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -250,7 +274,69 @@ const en = {
|
||||
tf3: 'Insert the TF card into a card reader and connect it to your computer.',
|
||||
tf4: 'Copy the image file to the /data directory on the TF card.',
|
||||
tf5: 'Insert the TF card into the NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Working on {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Enable the virtual disk?',
|
||||
desc: 'Mounting a medium needs the virtual disk, which is currently switched off. Enable it now? The keyboard and mouse of the target machine will disconnect for a moment.',
|
||||
menuHint: 'The virtual disk is disabled. Enable it in Settings - Device.',
|
||||
askDevice:
|
||||
'The virtual disk is disabled. An account with the device permission has to enable it first.'
|
||||
},
|
||||
add: {
|
||||
title: 'Add media',
|
||||
upload: 'Upload an ISO or IMG',
|
||||
fromUrl: 'Download from a URL',
|
||||
url: 'Image URL',
|
||||
urlRequired: 'Please enter a URL',
|
||||
onlyImages: 'Only .iso and .img files can be mounted',
|
||||
dropHint: 'Drag an ISO or IMG here, or click to choose a file',
|
||||
cdromOnlyIso: 'A CD-ROM can only carry an ISO 9660 image',
|
||||
uploadFailed: 'Upload failed',
|
||||
downloadFailed: 'Download failed',
|
||||
uploading: 'Uploading {{name}}',
|
||||
downloading: 'Downloading {{name}}',
|
||||
done: '{{name}} is ready',
|
||||
checksumFailed: 'The image does not match the SHA-256 checksum.',
|
||||
blocked: 'The target machine is using the disk',
|
||||
copyHint:
|
||||
'You can also copy an image into the data partition over SCP, or from the target machine.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Select a medium to browse the files inside it.',
|
||||
reasonAttached: 'This medium is attached to the target machine.',
|
||||
reasonAttachedHint:
|
||||
'Eject it first: the target machine has to release the disk before its files can be read.',
|
||||
reasonNotBrowsable: 'This medium holds a filesystem the file manager cannot open.',
|
||||
reasonNoLoop: 'This device cannot mount disk images (no loop support).',
|
||||
reasonISOImage:
|
||||
'An ISO 9660 image cannot be browsed here. It can still be mounted to the target machine.',
|
||||
empty: 'This folder is empty',
|
||||
noMatch: 'No files match the search.',
|
||||
free: '{{size}} free',
|
||||
search: 'Search this folder',
|
||||
sort: {
|
||||
name: 'Name',
|
||||
size: 'Size',
|
||||
type: 'Type',
|
||||
asc: 'Ascending',
|
||||
desc: 'Descending'
|
||||
},
|
||||
upload: 'Upload a file into the medium',
|
||||
deleteFolderTitle: 'Delete folder',
|
||||
deleteFolderConfirm:
|
||||
'Delete the folder {{name}} and everything inside it? This cannot be undone.',
|
||||
uploading: 'Uploading…',
|
||||
uploadFailed: 'Upload failed',
|
||||
download: 'Download',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete file',
|
||||
deleteConfirm: 'Delete {{name}} from the medium?'
|
||||
},
|
||||
yes: 'Yes',
|
||||
no: 'No'
|
||||
},
|
||||
script: {
|
||||
title: 'Scripts',
|
||||
@@ -292,21 +378,11 @@ const en = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Image Downloader',
|
||||
input: 'Please enter a remote image URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partition is RO, so we cannot download the image',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Drop file here or click to select',
|
||||
inputfile: 'Please enter the image File',
|
||||
NoISO: 'No ISO',
|
||||
disabled:
|
||||
'The data partition is read-only while the target machine uses it: new images cannot be written, existing files can still be downloaded.',
|
||||
sha256: 'SHA-256 (optional)',
|
||||
sha256Placeholder: 'Enter a 64-character SHA-256 checksum',
|
||||
invalidSHA256: 'SHA-256 must be a 64-character hexadecimal string',
|
||||
failed: 'Download failed',
|
||||
success: 'Download successful',
|
||||
checksumFailed: 'Download failed: SHA-256 verification failed',
|
||||
cancel: 'Cancel',
|
||||
cancelFailed: 'Failed to cancel download'
|
||||
},
|
||||
@@ -453,16 +529,18 @@ const en = {
|
||||
hidOnlyDesc: 'Stop emulating virtual devices, retaining only basic HID control',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount SD card on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host',
|
||||
reboot: 'Reboot',
|
||||
rebootDesc: 'Are you sure you want to reboot NanoKVM?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Attention',
|
||||
updateFailed: 'Failed to update the virtual device'
|
||||
},
|
||||
network: {
|
||||
title: 'Network',
|
||||
@@ -620,7 +698,68 @@ const en = {
|
||||
passwordUpdated: 'Password updated',
|
||||
loadFailed: 'Failed to load users',
|
||||
saveFailed: 'Failed to save user',
|
||||
deleteFailed: 'Failed to delete user'
|
||||
deleteFailed: 'Failed to delete user',
|
||||
editPermissions: 'Permissions',
|
||||
mcpLossTitle: 'Revoke MCP access',
|
||||
mcpLossDesc:
|
||||
'{{name}} will lose MCP access. The MCP API key is shared by the whole device, so a copy of it keeps working until the key is regenerated. Regenerating it disconnects every MCP client until it is given the new key.',
|
||||
mcpLossRotate: 'Regenerate the MCP API key',
|
||||
mcpLossKeep:
|
||||
'The current key stays valid, and whoever has a copy can still control the keyboard and mouse. You can regenerate it later in the MCP settings.',
|
||||
mcpKeyRotated: 'MCP API key regenerated. MCP clients need the new key.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Permissions',
|
||||
editTitle: 'Edit Permissions',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
close: 'Close',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Target machine',
|
||||
groupLibrary: 'Media library',
|
||||
groupHost: 'This device',
|
||||
groupServices: 'Services',
|
||||
consoleView: 'Watch the screen',
|
||||
consoleControl: 'Keyboard & mouse',
|
||||
power: 'Power & wake',
|
||||
mediumAttach: 'Attach a medium',
|
||||
libraryRead: 'Browse the media library',
|
||||
libraryWrite: 'Manage the media library',
|
||||
terminal: 'Terminal & scripts',
|
||||
device: 'Device settings',
|
||||
network: 'Network settings',
|
||||
update: 'System update',
|
||||
mcp: 'MCP service',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI assistant',
|
||||
effectConsoleView:
|
||||
'Shows the target screen, and sets the video mode, resolution, frame rate and quality that every viewer shares.',
|
||||
effectConsoleControl: 'Sends keyboard and mouse input to the target machine.',
|
||||
effectPower:
|
||||
'Powers on, powers off and resets the target machine, and manages the Wake-on-LAN device list.',
|
||||
effectMediumAttach:
|
||||
'Mounts and ejects media on the target machine, and exchanges files with it through the shared partition.',
|
||||
effectLibraryRead: 'Lists and downloads the images and the files inside them.',
|
||||
effectLibraryWrite:
|
||||
'Uploads, creates and deletes images, and changes the files inside them.',
|
||||
effectTerminal: 'Gives a root shell on this device, without the web session in between.',
|
||||
effectDevice:
|
||||
'Enables SSH (also root), reboots the device, hands the raw data partition to the target machine, and changes the OLED, HDMI, mDNS, memory and swap settings.',
|
||||
effectNetwork: 'Changes Wi-Fi, DNS and TLS, which can take this device off the network.',
|
||||
effectUpdate: 'Installs firmware and application packages.',
|
||||
effectMcp:
|
||||
'Issues a device-wide API key that drives the keyboard, mouse and screenshots outside the web session; a copied key keeps working after this grant is revoked unless the key is regenerated, which is offered when the grant is removed.',
|
||||
effectTailscale: 'Joins this device to a VPN mesh.',
|
||||
effectAi:
|
||||
'Drives the device through a local interface that the other grants do not restrict, so this is effectively full device control.',
|
||||
fullControlWarning: 'These grants amount to full device control:',
|
||||
emptyWarning: 'This account will have no features at all and can only sign in.',
|
||||
impliedBy: 'Included by {{name}}',
|
||||
grantCount_one: '{{count}} grant',
|
||||
grantCount: '{{count}} grants',
|
||||
adminAll: 'Administrators hold every permission',
|
||||
catalogFailed:
|
||||
'The permission catalog could not be loaded. Close this dialog and try again.'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ const es = {
|
||||
login: 'Inicio de sesión',
|
||||
changePassword: 'Cambiar contraseña',
|
||||
terminal: 'Consola',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Imágenes y uso compartido de archivos'
|
||||
},
|
||||
auth: {
|
||||
login: 'Iniciar sesión',
|
||||
@@ -40,7 +41,10 @@ const es = {
|
||||
change2: 'Contraseña de acceso web',
|
||||
change3: 'Contraseña root del sistema (contraseña de acceso por SSH)',
|
||||
change4: 'Para restablecer las contraseñas, mantén pulsado el botón BOOT del NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Contraseña actual',
|
||||
passwordLength: 'La contraseña debe tener entre 8 y 72 caracteres',
|
||||
invalidCurrentPassword: 'La contraseña actual no es correcta'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const es = {
|
||||
verifyBtn: 'Verificar'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Solo lectura',
|
||||
readOnlyTip:
|
||||
'Esta cuenta no puede enviar entrada de teclado ni de ratón al equipo de destino, por lo que la consola solo muestra la pantalla.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Esta cuenta no tiene acceso a la consola',
|
||||
description:
|
||||
'Se le concedieron otras capacidades. Abra una de las páginas que puede usar o pida a un administrador acceso a la consola.',
|
||||
nothing: 'Esta cuenta aún no tiene permisos. Pida a un administrador que le conceda alguno.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Escala',
|
||||
title: 'Pantalla',
|
||||
@@ -215,33 +230,44 @@ const es = {
|
||||
disable: 'Desactivar modo sólo HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Imágenes',
|
||||
loading: 'Cargando...',
|
||||
empty: 'No se ha encontrado nada',
|
||||
storage: {
|
||||
title: 'Imágenes y uso compartido de archivos',
|
||||
empty:
|
||||
'Aún no hay imágenes. Sube una ISO o IMG, o copia una en el disco desde el equipo remoto.',
|
||||
notAttached: 'Nada montado',
|
||||
attached: 'Montado: {{name}}',
|
||||
eject: 'Expulsar',
|
||||
mount: 'Montar',
|
||||
mountMode: 'Modo de montaje',
|
||||
mountFailed: 'Fallo al montar',
|
||||
mountDesc:
|
||||
'En algunos sistemas, es necesario expulsar el disco virtual del host remoto antes de montar una imagen.',
|
||||
diskOnWarn:
|
||||
'Montar la imagen desactiva el disco virtual, por lo que el equipo ya no puede acceder a /data. ¿Continuar?',
|
||||
diskOffWarn:
|
||||
'El disco virtual está desactivado y hay que activarlo para montar la imagen. Los dispositivos USB del equipo se enumeran de nuevo. ¿Continuar?',
|
||||
diskShared:
|
||||
'El disco virtual está activado, así que /data es de solo lectura aquí. Desactívalo para eliminar o subir imágenes.',
|
||||
unmountFailed: 'Fallo al desmontar',
|
||||
unmountDesc:
|
||||
'En algunos sistemas, es necesario expulsar manualmente el disco virtual desde el host remoto antes de desmontar la imagen.',
|
||||
refresh: 'Actualizar la lista de imágenes',
|
||||
attention: 'Atención',
|
||||
deleteConfirm: '¿Estás seguro de que deseas eliminar esta imagen?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No',
|
||||
mountModeDisk: 'Almacenamiento masivo',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Abrir el gestor de archivos',
|
||||
refresh: 'Actualizar',
|
||||
delete: 'Eliminar',
|
||||
deleteTitle: 'Eliminar el medio',
|
||||
deleteConfirm: '¿Eliminar {{name}}? Los archivos que contiene se perderán.',
|
||||
downloadImage: 'Descargar la imagen',
|
||||
typePartition: 'Archivos compartidos',
|
||||
volumeLabel: 'Disco extraíble',
|
||||
groups: {
|
||||
share: 'Uso compartido de archivos',
|
||||
images: 'Imágenes'
|
||||
},
|
||||
failed: 'La operación ha fallado',
|
||||
hidOnly: 'Modo solo HID: el disco virtual no está disponible',
|
||||
disabled: 'El disco virtual está desactivado (Ajustes - Dispositivo)',
|
||||
force: {
|
||||
title: '¿Recuperar el medio por la fuerza?',
|
||||
desc: 'El equipo remoto todavía está usando este medio. Recuperarlo vuelve a enumerar el dispositivo USB y puede perder todo lo que aún no ha escrito en el disco.',
|
||||
menuHint:
|
||||
'El equipo remoto está usando este medio. Abre el gestor de archivos para recuperarlo por la fuerza.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Cómo subir imágenes',
|
||||
usb1: 'Conecta el NanoKVM a tu computadora mediante USB.',
|
||||
usb2: 'Asegúrate de que el disco virtual esté montado (Ajustes - Disco Virtual).',
|
||||
usb2: 'En esta página, monta los archivos compartidos en modo de almacenamiento masivo.',
|
||||
usb3: 'Abre el disco virtual en tu computadora y copia el archivo de imagen en el directorio raíz del disco virtual.',
|
||||
usb4: 'Expulsa el disco cuando termine la copia: la imagen aparecerá entonces en la lista.',
|
||||
scp1: 'Asegúrate de que el NanoKVM y tu computadora estén en la misma red local.',
|
||||
scp2: 'Abre una terminal en tu computadora y usa el comando SCP para subir el archivo de imagen al directorio /data en el NanoKVM.',
|
||||
scp3: 'Ejemplo: scp tu-ruta-de-imagen root@tu-ip-del-nanokvm:/data',
|
||||
@@ -251,7 +277,70 @@ const es = {
|
||||
tf3: 'Inserta la tarjeta SD en un lector de tarjetas y conéctalo a tu computadora.',
|
||||
tf4: 'Copia el archivo de imagen en el directorio /data de la tarjeta SD.',
|
||||
tf5: 'Inserta la tarjeta SD en el NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Procesando {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: '¿Activar el disco virtual?',
|
||||
desc: 'Para montar un medio se necesita el disco virtual, que ahora está desactivado. ¿Activarlo ahora? El teclado y el ratón del equipo remoto se desconectarán un momento.',
|
||||
menuHint: 'El disco virtual está desactivado. Actívalo en Ajustes - Dispositivo.',
|
||||
askDevice:
|
||||
'El disco virtual está desactivado. Una cuenta con el permiso de dispositivo debe activarlo primero.'
|
||||
},
|
||||
add: {
|
||||
title: 'Añadir medio',
|
||||
upload: 'Subir una ISO o IMG',
|
||||
fromUrl: 'Descargar desde una URL',
|
||||
url: 'URL de la imagen',
|
||||
urlRequired: 'Introduce una URL',
|
||||
onlyImages: 'Solo se pueden montar archivos .iso y .img',
|
||||
dropHint: 'Arrastra una ISO o IMG aquí, o haz clic para elegir un archivo',
|
||||
cdromOnlyIso: 'Un CD-ROM solo puede contener una imagen ISO 9660',
|
||||
uploadFailed: 'La subida ha fallado',
|
||||
downloadFailed: 'La descarga ha fallado',
|
||||
uploading: 'Subiendo {{name}}',
|
||||
downloading: 'Descargando {{name}}',
|
||||
done: '{{name}} está listo',
|
||||
checksumFailed: 'La imagen no coincide con la suma de comprobación SHA-256.',
|
||||
blocked: 'El equipo remoto está usando el disco',
|
||||
copyHint:
|
||||
'También puedes copiar una imagen a la partición de datos por SCP, o desde el equipo remoto.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Selecciona un medio para ver los archivos que contiene.',
|
||||
reasonAttached: 'Este medio está conectado al equipo remoto.',
|
||||
reasonAttachedHint:
|
||||
'Expúlsalo primero: el equipo remoto debe liberar el disco antes de que se puedan leer sus archivos.',
|
||||
reasonNotBrowsable:
|
||||
'Este medio contiene un sistema de archivos que el gestor no puede abrir.',
|
||||
reasonNoLoop: 'Este dispositivo no puede montar imágenes de disco (sin soporte de loop).',
|
||||
reasonISOImage:
|
||||
'Una imagen ISO 9660 no se puede explorar aquí. Se puede montar igualmente en el equipo remoto.',
|
||||
empty: 'Esta carpeta está vacía',
|
||||
noMatch: 'Ningún archivo coincide con la búsqueda.',
|
||||
free: '{{size}} libres',
|
||||
search: 'Buscar en esta carpeta',
|
||||
sort: {
|
||||
name: 'Nombre',
|
||||
size: 'Tamaño',
|
||||
type: 'Tipo',
|
||||
asc: 'Ascendente',
|
||||
desc: 'Descendente'
|
||||
},
|
||||
upload: 'Subir un archivo al medio',
|
||||
deleteFolderTitle: 'Eliminar carpeta',
|
||||
deleteFolderConfirm:
|
||||
'¿Eliminar la carpeta {{name}} y todo su contenido? Esta acción no se puede deshacer.',
|
||||
uploading: 'Subiendo…',
|
||||
uploadFailed: 'La subida ha fallado',
|
||||
download: 'Descargar',
|
||||
delete: 'Eliminar',
|
||||
deleteTitle: 'Eliminar el archivo',
|
||||
deleteConfirm: '¿Eliminar {{name}} del medio?'
|
||||
},
|
||||
yes: 'Sí',
|
||||
no: 'No'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -293,21 +382,11 @@ const es = {
|
||||
ok: 'Aceptar'
|
||||
},
|
||||
download: {
|
||||
title: 'Descargador de imágenes',
|
||||
input: 'Por favor, introduce la URL de una imagen remota',
|
||||
ok: 'Aceptar',
|
||||
disabled: 'La partición /data es de sólo lectura, no se puede descargar la imagen',
|
||||
diskShared: 'Desactiva primero el disco virtual y luego usa la descarga de imágenes.',
|
||||
diskOff: 'Desactivar el disco virtual',
|
||||
uploadbox: 'Suelte el archivo aquí o haga clic para seleccionar',
|
||||
inputfile: 'Por favor ingrese el archivo de imagen',
|
||||
NoISO: 'Sin ISO',
|
||||
disabled:
|
||||
'La partición de datos es de solo lectura mientras la usa el equipo remoto: no se pueden escribir imágenes nuevas, pero los archivos existentes se siguen pudiendo descargar.',
|
||||
sha256: 'SHA-256 (opcional)',
|
||||
sha256Placeholder: 'Introduzca una suma de comprobación SHA-256 de 64 caracteres',
|
||||
invalidSHA256: 'SHA-256 debe ser una cadena hexadecimal de 64 caracteres',
|
||||
failed: 'Descarga fallida',
|
||||
success: 'Descarga correcta',
|
||||
checksumFailed: 'Descarga fallida: error en la verificación SHA-256',
|
||||
cancel: 'Cancelar',
|
||||
cancelFailed: 'No se pudo cancelar la descarga'
|
||||
},
|
||||
@@ -458,17 +537,19 @@ const es = {
|
||||
'Dejar de emular dispositivos virtuales y conservar solo el control básico HID',
|
||||
disk: 'Disco Virtual',
|
||||
diskDesc: 'Montar disco virtual en el host remoto',
|
||||
diskLocked: 'Desactivado mientras hay una imagen montada (activarlo cancela el montaje)',
|
||||
diskCancelMount:
|
||||
'Activar el disco virtual cancela la imagen montada e interrumpe una descarga o subida de imagen en curso. ¿Continuar?',
|
||||
diskBreakDownload:
|
||||
'Activar el disco virtual interrumpe la descarga de imágenes. ¿Continuar?',
|
||||
network: 'Red Virtual',
|
||||
networkDesc: 'Montar tarjeta de red virtual en el host remoto',
|
||||
reboot: 'Reiniciar',
|
||||
rebootDesc: '¿Estás seguro de que deseas reiniciar el NanoKVM?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
diskLocked: 'Desactivado mientras hay una imagen montada (activarlo cancela el montaje)',
|
||||
diskCancelMount:
|
||||
'Activar el disco virtual cancela la imagen montada e interrumpe una descarga o subida de imagen en curso. ¿Continuar?',
|
||||
diskBreakDownload:
|
||||
'Activar el disco virtual interrumpe la descarga de imágenes. ¿Continuar?',
|
||||
attention: 'Atención',
|
||||
updateFailed: 'No se pudo actualizar el dispositivo virtual'
|
||||
},
|
||||
network: {
|
||||
title: 'Red',
|
||||
@@ -611,7 +692,90 @@ const es = {
|
||||
logoutBtn: 'Cerrar sesión',
|
||||
logoutDesc: '¿Estás seguro de que deseas cerrar sesión?',
|
||||
okBtn: 'Sí',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
role: 'Rol',
|
||||
roles: {
|
||||
admin: 'Administrador',
|
||||
user: 'Usuario'
|
||||
},
|
||||
users: {
|
||||
title: 'Usuarios',
|
||||
create: 'Crear usuario',
|
||||
enabled: 'Activado',
|
||||
disabled: 'Desactivado',
|
||||
deviceOwner: 'Propietario del dispositivo',
|
||||
resetPassword: 'Restablecer contraseña',
|
||||
delete: 'Eliminar',
|
||||
deleteConfirm: '¿Eliminar este usuario y revocar todas sus sesiones?',
|
||||
created: 'Usuario creado',
|
||||
deleted: 'Usuario eliminado',
|
||||
passwordUpdated: 'Contraseña actualizada',
|
||||
loadFailed: 'No se pudieron cargar los usuarios',
|
||||
saveFailed: 'No se pudo guardar el usuario',
|
||||
deleteFailed: 'No se pudo eliminar el usuario',
|
||||
editPermissions: 'Permisos',
|
||||
mcpLossTitle: 'Retirar el acceso MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} perderá el acceso MCP. La clave API de MCP la comparte todo el dispositivo, así que una copia sigue funcionando hasta que se regenere. Regenerarla desconecta a todos los clientes MCP hasta que reciban la nueva clave.',
|
||||
mcpLossRotate: 'Regenerar la clave API de MCP',
|
||||
mcpLossKeep:
|
||||
'La clave actual sigue siendo válida y quien tenga una copia aún puede controlar el teclado y el ratón. Puede regenerarla más tarde en los ajustes de MCP.',
|
||||
mcpKeyRotated: 'Clave API de MCP regenerada. Los clientes MCP necesitan la nueva clave.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Permisos',
|
||||
editTitle: 'Editar permisos',
|
||||
save: 'Guardar',
|
||||
saved: 'Guardado',
|
||||
close: 'Cerrar',
|
||||
groupConsole: 'Consola',
|
||||
groupTarget: 'Máquina de destino',
|
||||
groupLibrary: 'Biblioteca de medios',
|
||||
groupHost: 'Este dispositivo',
|
||||
groupServices: 'Servicios',
|
||||
consoleView: 'Ver la pantalla',
|
||||
consoleControl: 'Teclado y ratón',
|
||||
power: 'Encendido y activación',
|
||||
mediumAttach: 'Conectar un medio',
|
||||
libraryRead: 'Explorar la biblioteca',
|
||||
libraryWrite: 'Gestionar la biblioteca',
|
||||
terminal: 'Terminal y scripts',
|
||||
device: 'Ajustes del dispositivo',
|
||||
network: 'Ajustes de red',
|
||||
update: 'Actualización del sistema',
|
||||
mcp: 'Servicio MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Asistente de IA',
|
||||
effectConsoleView:
|
||||
'Muestra la pantalla de destino y define el modo de vídeo, la resolución, los fps y la calidad que comparten todos los espectadores.',
|
||||
effectConsoleControl: 'Envía entrada de teclado y ratón a la máquina de destino.',
|
||||
effectPower:
|
||||
'Enciende, apaga y reinicia la máquina de destino, y gestiona la lista de dispositivos de Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Monta y expulsa medios en la máquina de destino, e intercambia archivos con ella mediante la partición compartida.',
|
||||
effectLibraryRead: 'Lista y descarga las imágenes y los archivos que contienen.',
|
||||
effectLibraryWrite:
|
||||
'Sube, crea y elimina imágenes, y modifica los archivos que contienen.',
|
||||
effectTerminal:
|
||||
'Ofrece un shell de root en este dispositivo, sin pasar por la sesión web.',
|
||||
effectDevice:
|
||||
'Permite activar SSH (también root), reiniciar el dispositivo, entregar la partición de datos sin formato a la máquina de destino y cambiar OLED, HDMI, mDNS, memoria y swap.',
|
||||
effectNetwork: 'Cambia Wi-Fi, DNS y TLS, lo que puede dejar este dispositivo sin red.',
|
||||
effectUpdate: 'Instala firmware y paquetes de aplicación.',
|
||||
effectMcp:
|
||||
'Emite una clave API de todo el dispositivo que controla teclado, ratón y capturas fuera de la sesión web; una copia sigue funcionando tras revocar el permiso salvo que se regenere la clave, algo que se ofrece al retirar el permiso.',
|
||||
effectTailscale: 'Une este dispositivo a una malla VPN.',
|
||||
effectAi:
|
||||
'Controla el dispositivo mediante una interfaz local que los demás permisos no limitan, por lo que equivale al control total del dispositivo.',
|
||||
fullControlWarning: 'Estos permisos equivalen al control total del dispositivo:',
|
||||
emptyWarning: 'Esta cuenta no tendrá ninguna función y solo podrá iniciar sesión.',
|
||||
impliedBy: 'Incluido por {{name}}',
|
||||
grantCount_one: '{{count}} permiso',
|
||||
grantCount: '{{count}} permisos',
|
||||
adminAll: 'Los administradores tienen todos los permisos',
|
||||
catalogFailed:
|
||||
'No se pudo cargar la lista de permisos. Cierre este diálogo e inténtelo de nuevo.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const fr = {
|
||||
login: 'Connexion',
|
||||
changePassword: 'Changer le mot de passe',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Images et partage de fichiers'
|
||||
},
|
||||
auth: {
|
||||
login: 'Connexion',
|
||||
@@ -41,7 +42,10 @@ const fr = {
|
||||
change3: 'Mot de passe racine du système (mot de passe de connexion SSH)',
|
||||
change4:
|
||||
'Pour réinitialiser les mots de passe, appuyez et maintenez enfoncé le bouton BOOT sur le NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Mot de passe actuel',
|
||||
passwordLength: 'Le mot de passe doit contenir entre 8 et 72 caractères',
|
||||
invalidCurrentPassword: 'Le mot de passe actuel est incorrect'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -61,6 +65,18 @@ const fr = {
|
||||
verifyBtn: 'Vérifier'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Lecture seule',
|
||||
readOnlyTip:
|
||||
"Ce compte ne peut pas envoyer de saisie clavier ou souris vers la machine cible ; la console affiche donc uniquement l'écran."
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Ce compte n’a pas accès à la console',
|
||||
description:
|
||||
'D’autres capacités lui ont été accordées. Ouvrez l’une des pages qu’il peut utiliser ou demandez à un administrateur l’accès à la console.',
|
||||
nothing:
|
||||
'Ce compte n’a encore aucune autorisation. Demandez à un administrateur de lui en accorder.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Échelle',
|
||||
title: 'Écran',
|
||||
@@ -217,33 +233,44 @@ const fr = {
|
||||
disable: 'Désactiver le mode HID uniquement'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Images',
|
||||
loading: 'Chargement',
|
||||
empty: 'Vide',
|
||||
storage: {
|
||||
title: 'Images et partage de fichiers',
|
||||
empty:
|
||||
"Aucune image pour l'instant. Envoyez une ISO ou une IMG, ou copiez-en une sur le disque depuis la machine distante.",
|
||||
notAttached: 'Rien de monté',
|
||||
attached: 'Monté : {{name}}',
|
||||
eject: 'Éjecter',
|
||||
mount: 'Monter',
|
||||
mountMode: 'Mode de montage',
|
||||
mountFailed: "Échec du montage de l'image.",
|
||||
mountDesc:
|
||||
"Dans certains systèmes, il est nécessaire de déséjecter le disque virtuel sur l'hôte distant avant de monter l'image.",
|
||||
diskOnWarn:
|
||||
"Monter l'image désactive le disque virtuel : l'ordinateur ne peut plus accéder à /data. Continuer ?",
|
||||
diskOffWarn:
|
||||
"Le disque virtuel est désactivé et doit être activé pour monter l'image. Les périphériques USB de l'ordinateur sont réénumérés. Continuer ?",
|
||||
diskShared:
|
||||
'Le disque virtuel est activé, donc /data est en lecture seule ici. Désactivez-le pour supprimer ou envoyer des images.',
|
||||
unmountFailed: 'Échec du démontage',
|
||||
unmountDesc:
|
||||
"Sur certains systèmes, vous devez l'éjecter manuellement de l'hôte distant avant de démonter l'image.",
|
||||
refresh: 'Actualiser la liste des images',
|
||||
attention: 'Attention',
|
||||
deleteConfirm: 'Etes-vous sûr de vouloir supprimer cette image?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non',
|
||||
mountModeDisk: 'Stockage de masse',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Ouvrir le gestionnaire de fichiers',
|
||||
refresh: 'Actualiser',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer le support',
|
||||
deleteConfirm: "Supprimer {{name}} ? Les fichiers qu'il contient seront perdus.",
|
||||
downloadImage: "Télécharger l'image",
|
||||
typePartition: 'Fichiers partagés',
|
||||
volumeLabel: 'Disque amovible',
|
||||
groups: {
|
||||
share: 'Partage de fichiers',
|
||||
images: 'Images'
|
||||
},
|
||||
failed: "L'opération a échoué",
|
||||
hidOnly: 'Mode HID uniquement : le disque virtuel est indisponible',
|
||||
disabled: 'Le disque virtuel est désactivé (Paramètres - Appareil)',
|
||||
force: {
|
||||
title: 'Reprendre le support de force ?',
|
||||
desc: "La machine distante utilise encore ce support. Le reprendre réénumère le périphérique USB et peut perdre tout ce qu'elle n'a pas encore écrit sur le disque.",
|
||||
menuHint:
|
||||
'La machine distante utilise ce support. Ouvrez le gestionnaire de fichiers pour le reprendre de force.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Comment télécharger',
|
||||
usb1: 'Connectez le NanoKVM à votre ordinateur via USB.',
|
||||
usb2: 'Assurez-vous que le disque virtuel est monté (Paramètres - Disque virtuel).',
|
||||
usb2: 'Sur cette page, montez les fichiers partagés en mode stockage de masse.',
|
||||
usb3: 'Ouvrez le disque virtuel sur votre ordinateur et copiez le fichier image dans le répertoire racine du disque virtuel.',
|
||||
usb4: "Éjectez le disque une fois la copie terminée : l'image apparaît alors dans la liste.",
|
||||
scp1: 'Assurez-vous que le NanoKVM et votre ordinateur sont sur le même réseau local.',
|
||||
scp2: 'Ouvrez un terminal sur votre ordinateur et utilisez la commande SCP pour copier le fichier image dans le répertoire /data du NanoKVM.',
|
||||
scp3: 'Exemple : scp chemin-de-votre-image root@ip-de-votre-nanokvm:/data',
|
||||
@@ -253,7 +280,71 @@ const fr = {
|
||||
tf3: 'Insérez la carte TF dans un lecteur de carte et connectez-la à votre ordinateur.',
|
||||
tf4: 'Copiez le fichier image dans le répertoire /data de la carte TF sur votre ordinateur.',
|
||||
tf5: 'Réinsérez la carte TF dans le NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Traitement de {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Activer le disque virtuel ?',
|
||||
desc: "Monter un support nécessite le disque virtuel, actuellement désactivé. L'activer maintenant ? Le clavier et la souris de la machine distante seront brièvement déconnectés.",
|
||||
menuHint: 'Le disque virtuel est désactivé. Activez-le dans Paramètres - Appareil.',
|
||||
askDevice:
|
||||
"Le disque virtuel est désactivé. Un compte disposant de l'autorisation appareil doit d'abord l'activer."
|
||||
},
|
||||
add: {
|
||||
title: 'Ajouter un support',
|
||||
upload: 'Envoyer une ISO ou une IMG',
|
||||
fromUrl: 'Télécharger depuis une URL',
|
||||
url: "URL de l'image",
|
||||
urlRequired: 'Veuillez saisir une URL',
|
||||
onlyImages: 'Seuls les fichiers .iso et .img peuvent être montés',
|
||||
dropHint: 'Glissez une ISO ou IMG ici, ou cliquez pour choisir un fichier',
|
||||
cdromOnlyIso: 'Un CD-ROM ne peut contenir qu’une image ISO 9660',
|
||||
uploadFailed: "Échec de l'envoi",
|
||||
downloadFailed: 'Échec du téléchargement',
|
||||
uploading: 'Envoi de {{name}}',
|
||||
downloading: 'Téléchargement de {{name}}',
|
||||
done: '{{name}} est prêt',
|
||||
checksumFailed: "L'image ne correspond pas à la somme de contrôle SHA-256.",
|
||||
blocked: 'La machine distante utilise le disque',
|
||||
copyHint:
|
||||
'Vous pouvez aussi copier une image dans la partition de données via SCP, ou depuis la machine distante.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: "Sélectionnez un support pour parcourir les fichiers qu'il contient.",
|
||||
reasonAttached: 'Ce support est connecté à la machine distante.',
|
||||
reasonAttachedHint:
|
||||
"Éjectez-le d'abord : la machine distante doit libérer le disque avant que ses fichiers puissent être lus.",
|
||||
reasonNotBrowsable:
|
||||
'Ce support contient un système de fichiers que le gestionnaire ne peut pas ouvrir.',
|
||||
reasonNoLoop:
|
||||
"Cet appareil ne peut pas monter d'images disque (pas de prise en charge de loop).",
|
||||
empty: 'Ce dossier est vide',
|
||||
reasonISOImage:
|
||||
'Une image ISO 9660 ne peut pas être parcourue ici. Elle peut toujours être montée sur la machine distante.',
|
||||
noMatch: 'Aucun fichier ne correspond à la recherche.',
|
||||
free: '{{size}} libres',
|
||||
search: 'Rechercher dans ce dossier',
|
||||
sort: {
|
||||
name: 'Nom',
|
||||
size: 'Taille',
|
||||
type: 'Type',
|
||||
asc: 'Croissant',
|
||||
desc: 'Décroissant'
|
||||
},
|
||||
upload: 'Envoyer un fichier sur le support',
|
||||
deleteFolderTitle: 'Supprimer le dossier',
|
||||
deleteFolderConfirm:
|
||||
'Supprimer le dossier {{name}} et tout son contenu ? Cette action est irréversible.',
|
||||
uploading: 'Envoi…',
|
||||
uploadFailed: "Échec de l'envoi",
|
||||
download: 'Télécharger',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer le fichier',
|
||||
deleteConfirm: 'Supprimer {{name}} du support ?'
|
||||
},
|
||||
yes: 'Oui',
|
||||
no: 'Non'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -295,21 +386,11 @@ const fr = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Télécharger l’image',
|
||||
input: 'Veuillez entrer l’URL d’une image distante',
|
||||
ok: 'Ok',
|
||||
disabled: 'La partition /data est en lecture seule, impossible de télécharger l’image',
|
||||
diskShared: "Désactivez d'abord le disque virtuel, puis utilisez le téléchargement d'images.",
|
||||
diskOff: 'Désactiver le disque virtuel',
|
||||
uploadbox: 'Déposez le fichier ici ou cliquez pour sélectionner',
|
||||
inputfile: 'Veuillez saisir le fichier image',
|
||||
NoISO: 'Aucun ISO',
|
||||
disabled:
|
||||
'La partition de données est en lecture seule pendant que la machine distante l’utilise : impossible d’y écrire de nouvelles images, mais les fichiers existants restent téléchargeables.',
|
||||
sha256: 'SHA-256 (facultatif)',
|
||||
sha256Placeholder: 'Saisissez une somme de contrôle SHA-256 de 64 caractères',
|
||||
invalidSHA256: 'SHA-256 doit être une chaîne hexadécimale de 64 caractères',
|
||||
failed: 'Échec du téléchargement',
|
||||
success: 'Téléchargement réussi',
|
||||
checksumFailed: 'Échec du téléchargement : échec de la vérification SHA-256',
|
||||
cancel: 'Annuler',
|
||||
cancelFailed: 'Impossible d’annuler le téléchargement'
|
||||
},
|
||||
@@ -460,17 +541,19 @@ const fr = {
|
||||
"Arrêtez d'émuler des périphériques virtuels, en ne conservant que le contrôle de base HID",
|
||||
disk: 'Disque virtuel',
|
||||
diskDesc: "Monter le disque virtuel U sur l'hôte distant",
|
||||
diskLocked: "Désactivé tant qu'une image est montée (l'activer annule le montage)",
|
||||
diskCancelMount:
|
||||
"Activer le disque virtuel annule l'image montée et interrompt un téléchargement ou un envoi d'image en cours. Continuer ?",
|
||||
diskBreakDownload:
|
||||
"Activer le disque virtuel interrompt le téléchargement d'images. Continuer ?",
|
||||
network: 'Réseau virtuel',
|
||||
networkDesc: "Monter la carte réseau virtuelle sur l'hôte distant",
|
||||
reboot: 'Redémarrer',
|
||||
rebootDesc: 'Êtes-vous sûr de vouloir redémarrer NanoKVM?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
cancelBtn: 'Non',
|
||||
diskLocked: "Désactivé tant qu'une image est montée (l'activer annule le montage)",
|
||||
diskCancelMount:
|
||||
"Activer le disque virtuel annule l'image montée et interrompt un téléchargement ou un envoi d'image en cours. Continuer ?",
|
||||
diskBreakDownload:
|
||||
"Activer le disque virtuel interrompt le téléchargement d'images. Continuer ?",
|
||||
attention: 'Attention',
|
||||
updateFailed: 'Échec de la mise à jour du périphérique virtuel'
|
||||
},
|
||||
network: {
|
||||
title: 'Réseau',
|
||||
@@ -611,7 +694,90 @@ const fr = {
|
||||
logoutBtn: 'Déconnexion',
|
||||
logoutDesc: 'Êtes-vous sûr de vouloir vous déconnecter?',
|
||||
okBtn: 'Oui',
|
||||
cancelBtn: 'Non'
|
||||
cancelBtn: 'Non',
|
||||
role: 'Rôle',
|
||||
roles: {
|
||||
admin: 'Administrateur',
|
||||
user: 'Utilisateur'
|
||||
},
|
||||
users: {
|
||||
title: 'Utilisateurs',
|
||||
create: 'Créer un utilisateur',
|
||||
enabled: 'Activé',
|
||||
disabled: 'Désactivé',
|
||||
deviceOwner: "Propriétaire de l'appareil",
|
||||
resetPassword: 'Réinitialiser le mot de passe',
|
||||
delete: 'Supprimer',
|
||||
deleteConfirm: 'Supprimer cet utilisateur et révoquer toutes ses sessions ?',
|
||||
created: 'Utilisateur créé',
|
||||
deleted: 'Utilisateur supprimé',
|
||||
passwordUpdated: 'Mot de passe mis à jour',
|
||||
loadFailed: 'Échec du chargement des utilisateurs',
|
||||
saveFailed: "Échec de l'enregistrement de l'utilisateur",
|
||||
deleteFailed: "Échec de la suppression de l'utilisateur",
|
||||
editPermissions: 'Autorisations',
|
||||
mcpLossTitle: 'Retirer l’accès MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} perdra l’accès MCP. La clé API MCP est partagée par tout l’appareil : une copie reste valide jusqu’à sa régénération. La régénérer déconnecte tous les clients MCP jusqu’à ce qu’ils reçoivent la nouvelle clé.',
|
||||
mcpLossRotate: 'Régénérer la clé API MCP',
|
||||
mcpLossKeep:
|
||||
'La clé actuelle reste valide et toute personne qui en a une copie peut encore contrôler le clavier et la souris. Vous pourrez la régénérer plus tard dans les réglages MCP.',
|
||||
mcpKeyRotated: 'Clé API MCP régénérée. Les clients MCP ont besoin de la nouvelle clé.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Autorisations',
|
||||
editTitle: 'Modifier les autorisations',
|
||||
save: 'Enregistrer',
|
||||
saved: 'Enregistré',
|
||||
close: 'Fermer',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Machine cible',
|
||||
groupLibrary: 'Bibliothèque de médias',
|
||||
groupHost: 'Cet appareil',
|
||||
groupServices: 'Services',
|
||||
consoleView: 'Voir l’écran',
|
||||
consoleControl: 'Clavier et souris',
|
||||
power: 'Alimentation et réveil',
|
||||
mediumAttach: 'Attacher un média',
|
||||
libraryRead: 'Parcourir la bibliothèque',
|
||||
libraryWrite: 'Gérer la bibliothèque',
|
||||
terminal: 'Terminal et scripts',
|
||||
device: 'Réglages de l’appareil',
|
||||
network: 'Réglages réseau',
|
||||
update: 'Mise à jour système',
|
||||
mcp: 'Service MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Assistant IA',
|
||||
effectConsoleView:
|
||||
'Affiche l’écran cible et définit le mode vidéo, la résolution, la cadence et la qualité partagés par tous les spectateurs.',
|
||||
effectConsoleControl: 'Envoie les saisies clavier et souris à la machine cible.',
|
||||
effectPower:
|
||||
'Allume, éteint et redémarre la machine cible, et gère la liste des appareils Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Monte et éjecte des médias sur la machine cible, et échange des fichiers avec elle via la partition partagée.',
|
||||
effectLibraryRead: 'Liste et télécharge les images et les fichiers qu’elles contiennent.',
|
||||
effectLibraryWrite:
|
||||
'Envoie, crée et supprime des images, et modifie les fichiers qu’elles contiennent.',
|
||||
effectTerminal: 'Donne un shell root sur cet appareil, sans passer par la session web.',
|
||||
effectDevice:
|
||||
'Permet d’activer SSH (root également), de redémarrer l’appareil, de livrer la partition de données brute à la machine cible et de modifier OLED, HDMI, mDNS, mémoire et swap.',
|
||||
effectNetwork:
|
||||
'Modifie le Wi-Fi, le DNS et TLS, ce qui peut couper cet appareil du réseau.',
|
||||
effectUpdate: 'Installe des micrologiciels et des paquets d’application.',
|
||||
effectMcp:
|
||||
'Émet une clé API valable pour tout l’appareil qui pilote clavier, souris et captures hors de la session web ; une copie reste valide après révocation tant que la clé n’est pas régénérée, ce qui est proposé lors du retrait de l’autorisation.',
|
||||
effectTailscale: 'Rejoint cet appareil à un maillage VPN.',
|
||||
effectAi:
|
||||
'Pilote l’appareil via une interface locale que les autres autorisations ne limitent pas : cela équivaut au contrôle total de l’appareil.',
|
||||
fullControlWarning: 'Ces autorisations équivalent au contrôle total de l’appareil :',
|
||||
emptyWarning: 'Ce compte n’aura aucune fonction et pourra seulement se connecter.',
|
||||
impliedBy: 'Inclus par {{name}}',
|
||||
grantCount_one: '{{count}} autorisation',
|
||||
grantCount: '{{count}} autorisations',
|
||||
adminAll: 'Les administrateurs détiennent toutes les autorisations',
|
||||
catalogFailed:
|
||||
'Impossible de charger la liste des autorisations. Fermez ce dialogue et réessayez.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const hu = {
|
||||
login: 'Bejelentkezés',
|
||||
changePassword: 'Jelszó megváltoztatása',
|
||||
terminal: 'Terminál',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Képek és fájlmegosztás'
|
||||
},
|
||||
auth: {
|
||||
login: 'Bejelentkezés',
|
||||
@@ -40,7 +41,10 @@ const hu = {
|
||||
change2: 'Webes bejelentkezési jelszó',
|
||||
change3: 'Rendszer root jelszava (SSH bejelentkezési jelszó)',
|
||||
change4: 'A jelszavak visszaállításához tartsa lenyomva a BOOT gombot a NanoKVM-en.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Jelenlegi jelszó',
|
||||
passwordLength: 'A jelszónak 8 és 72 karakter között kell lennie',
|
||||
invalidCurrentPassword: 'A jelenlegi jelszó helytelen'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const hu = {
|
||||
verifyBtn: 'Ellenőrizze'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Csak megtekintés',
|
||||
readOnlyTip:
|
||||
'Ez a fiók nem tud billentyűzet- vagy egérbemenetet küldeni a célgépnek, ezért a konzol csak a képernyőt jeleníti meg.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Ennek a fióknak nincs konzolhozzáférése',
|
||||
description:
|
||||
'Más képességeket kapott. Nyissa meg az egyik használható oldalt, vagy kérjen konzolhozzáférést egy adminisztrátortól.',
|
||||
nothing: 'Ennek a fióknak még nincs engedélye. Kérjen engedélyeket egy adminisztrátortól.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skála',
|
||||
title: 'Képernyő',
|
||||
@@ -216,33 +231,44 @@ const hu = {
|
||||
disable: 'A HID-Csak mód letiltása'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Képek',
|
||||
loading: 'Betöltés...',
|
||||
empty: 'Nem található semmi',
|
||||
storage: {
|
||||
title: 'Képek és fájlmegosztás',
|
||||
empty:
|
||||
'Még nincsenek képek. Töltsön fel egy ISO vagy IMG fájlt, vagy másoljon egyet a lemezre a célgépről.',
|
||||
notAttached: 'Nincs csatlakoztatva semmi',
|
||||
attached: 'Csatlakoztatva: {{name}}',
|
||||
eject: 'Kiadás',
|
||||
mount: 'Csatlakoztatás',
|
||||
mountMode: 'Felszerelési mód',
|
||||
mountFailed: 'Csatlakoztatás sikertelen',
|
||||
mountDesc:
|
||||
'Egyes rendszerekben szükséges lehet a virtuális lemez eltávolítása a távoli gépen, mielőtt a képet csatlakoztatja.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'A leválasztás nem sikerült',
|
||||
unmountDesc:
|
||||
'Egyes rendszereken manuálisan kell kiadnia a távoli gazdagépről a kép leválasztása előtt.',
|
||||
refresh: 'Frissítse a képlistát',
|
||||
attention: 'Figyelem',
|
||||
deleteConfirm: 'Biztosan törli ezt a képet?',
|
||||
okBtn: 'Igen',
|
||||
cancelBtn: 'Nem',
|
||||
mountModeDisk: 'Háttértár',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Fájlkezelő megnyitása',
|
||||
refresh: 'Frissítés',
|
||||
delete: 'Törlés',
|
||||
deleteTitle: 'Adathordozó törlése',
|
||||
deleteConfirm: 'Törli a következőt: {{name}}? A benne lévő fájlok elvesznek.',
|
||||
downloadImage: 'A kép letöltése',
|
||||
typePartition: 'Megosztott fájlok',
|
||||
volumeLabel: 'Cserélhető lemez',
|
||||
groups: {
|
||||
share: 'Fájlmegosztás',
|
||||
images: 'Képek'
|
||||
},
|
||||
failed: 'A művelet nem sikerült',
|
||||
hidOnly: 'Csak HID mód: a virtuális lemez nem érhető el',
|
||||
disabled: 'A virtuális lemez ki van kapcsolva (Beállítások - Eszköz)',
|
||||
force: {
|
||||
title: 'Visszaveszi az adathordozót erővel?',
|
||||
desc: 'A célgép még használja ezt az adathordozót. A visszavétel újra felsorolja az USB-eszközt, és elveszhet minden, amit még nem írt a lemezre.',
|
||||
menuHint:
|
||||
'A célgép használja ezt az adathordozót. Nyissa meg a fájlkezelőt az erővel történő visszavételhez.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Hogyan tölts fel képeket',
|
||||
usb1: 'Csatlakoztassa a NanoKVM-t a számítógépéhez USB-n keresztül.',
|
||||
usb2: 'Győződjön meg róla, hogy a virtuális lemez csatlakoztatva van (Beállítások - Virtuális lemez).',
|
||||
usb2: 'Ezen az oldalon csatolja a megosztott fájlokat háttértár módban.',
|
||||
usb3: 'Nyissa meg a virtuális lemezt a számítógépén, és másolja a kép fájlt a virtuális lemez gyökérkönyvtárába.',
|
||||
usb4: 'A másolás végeztével adja ki a lemezt: a lemezkép ezután megjelenik a listában.',
|
||||
scp1: 'Győződjön meg róla, hogy a NanoKVM és a számítógépe ugyanazon a helyi hálózaton van.',
|
||||
scp2: 'Nyisson meg egy terminált a számítógépén, és használja az SCP parancsot a kép fájl feltöltésére a /data könyvtárba a NanoKVM-en.',
|
||||
scp3: 'Példa: scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -252,7 +278,71 @@ const hu = {
|
||||
tf3: 'Helyezze a TF kártyát egy kártyaolvasóba, és csatlakoztassa a számítógépéhez.',
|
||||
tf4: 'Másolja a képfájlt a TF kártya /data könyvtárába.',
|
||||
tf5: 'Helyezze vissza a TF kártyát a NanoKVM-be.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '{{name}} feldolgozása'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Bekapcsolja a virtuális lemezt?',
|
||||
desc: 'Az adathordozó csatlakoztatásához a virtuális lemez kell, amely jelenleg ki van kapcsolva. Bekapcsolja? A célgép billentyűzete és egere egy pillanatra leválik.',
|
||||
menuHint:
|
||||
'A virtuális lemez ki van kapcsolva. Kapcsolja be a Beállítások - Eszköz menüben.',
|
||||
askDevice:
|
||||
'A virtuális lemez ki van kapcsolva. Előbb egy eszköz-engedéllyel rendelkező fióknak kell bekapcsolnia.'
|
||||
},
|
||||
add: {
|
||||
title: 'Adathordozó hozzáadása',
|
||||
upload: 'ISO vagy IMG feltöltése',
|
||||
fromUrl: 'Letöltés URL-ről',
|
||||
url: 'A kép URL-címe',
|
||||
urlRequired: 'Adjon meg egy URL-t',
|
||||
onlyImages: 'Csak .iso és .img fájlok csatlakoztathatók',
|
||||
dropHint: 'Húzzon ide egy ISO vagy IMG fájlt, vagy kattintson a kiválasztáshoz',
|
||||
cdromOnlyIso: 'CD-ROM csak ISO 9660 képet tartalmazhat',
|
||||
uploadFailed: 'A feltöltés nem sikerült',
|
||||
downloadFailed: 'A letöltés nem sikerült',
|
||||
uploading: '{{name}} feltöltése',
|
||||
downloading: '{{name}} letöltése',
|
||||
done: '{{name}} elkészült',
|
||||
checksumFailed: 'A lemezkép nem egyezik a SHA-256 ellenőrzőösszeggel.',
|
||||
blocked: 'A célgép használja a lemezt',
|
||||
copyHint:
|
||||
'A képet SCP-vel is a data partícióra másolhatja, vagy a célgépről is átmásolhatja.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Válasszon adathordozót a rajta lévő fájlok megtekintéséhez.',
|
||||
reasonAttached: 'Ez az adathordozó a célgéphez van csatlakoztatva.',
|
||||
reasonAttachedHint:
|
||||
'Először adja ki: a célgépnek fel kell szabadítania a lemezt, mielőtt a fájlok olvashatók lennének.',
|
||||
reasonNotBrowsable:
|
||||
'Ez az adathordozó olyan fájlrendszert tartalmaz, amelyet a fájlkezelő nem tud megnyitni.',
|
||||
reasonNoLoop: 'Ez az eszköz nem tud lemezképeket csatlakoztatni (nincs loop támogatás).',
|
||||
reasonISOImage:
|
||||
'Az ISO 9660 képek itt nem böngészhetők. A célgépre továbbra is csatlakoztathatók.',
|
||||
empty: 'Ez a mappa üres',
|
||||
noMatch: 'Nincs a keresésnek megfelelő fájl.',
|
||||
free: '{{size}} szabad',
|
||||
search: 'Keresés ebben a mappában',
|
||||
sort: {
|
||||
name: 'Név',
|
||||
size: 'Méret',
|
||||
type: 'Típus',
|
||||
asc: 'Növekvő',
|
||||
desc: 'Csökkenő'
|
||||
},
|
||||
upload: 'Fájl feltöltése az adathordozóra',
|
||||
deleteFolderTitle: 'Mappa törlése',
|
||||
deleteFolderConfirm:
|
||||
'Törli a(z) {{name}} mappát és a teljes tartalmát? Ez nem vonható vissza.',
|
||||
uploading: 'Feltöltés…',
|
||||
uploadFailed: 'A feltöltés nem sikerült',
|
||||
download: 'Letöltés',
|
||||
delete: 'Törlés',
|
||||
deleteTitle: 'Fájl törlése',
|
||||
deleteConfirm: 'Törli a következőt: {{name}} az adathordozóról?'
|
||||
},
|
||||
yes: 'Igen',
|
||||
no: 'Nem'
|
||||
},
|
||||
script: {
|
||||
title: 'Szkriptek',
|
||||
@@ -294,21 +384,11 @@ const hu = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Képletöltő',
|
||||
input: 'Adjon meg egy távoli képet URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partíció RO, ezért nem tudjuk letölteni a képet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Dobja ide a fájlt, vagy kattintson a kiválasztáshoz',
|
||||
inputfile: 'Kérjük, írja be a képfájlt',
|
||||
NoISO: 'Nincs ISO',
|
||||
disabled:
|
||||
'A data partíció írásvédett, amíg a célgép használja: új kép nem írható rá, a meglévő fájlok viszont letölthetők.',
|
||||
sha256: 'SHA-256 (opcionális)',
|
||||
sha256Placeholder: 'Adjon meg egy 64 karakteres SHA-256 ellenőrzőösszeget',
|
||||
invalidSHA256: 'A SHA-256 értékének 64 karakteres hexadecimális karakterláncnak kell lennie',
|
||||
failed: 'Sikertelen letöltés',
|
||||
success: 'Sikeres letöltés',
|
||||
checksumFailed: 'Sikertelen letöltés: a SHA-256 ellenőrzése sikertelen',
|
||||
cancel: 'Mégse',
|
||||
cancelFailed: 'A letöltés megszakítása sikertelen'
|
||||
},
|
||||
@@ -458,16 +538,18 @@ const hu = {
|
||||
'A virtuális eszközök emulálásának leállítása, csak az alapvető HID vezérlés megtartásával',
|
||||
disk: 'Virtuális lemez',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuális hálózat',
|
||||
networkDesc: 'Virtuális hálózati kártya csatlakoztatása a távoli gazdagépen',
|
||||
reboot: 'Újraindítás',
|
||||
rebootDesc: 'Biztos, hogy újra akarja indítani a NanoKVM-t?',
|
||||
okBtn: 'Igen',
|
||||
cancelBtn: 'Nem'
|
||||
cancelBtn: 'Nem',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Figyelem',
|
||||
updateFailed: 'A virtuális eszköz frissítése nem sikerült'
|
||||
},
|
||||
network: {
|
||||
title: 'Hálózat',
|
||||
@@ -606,7 +688,89 @@ const hu = {
|
||||
logoutBtn: 'Kijelentkezés',
|
||||
logoutDesc: 'Biztos, hogy ki szeretne jelentkezni?',
|
||||
okBtn: 'Igen',
|
||||
cancelBtn: 'Nem'
|
||||
cancelBtn: 'Nem',
|
||||
role: 'Szerepkör',
|
||||
roles: {
|
||||
admin: 'Adminisztrátor',
|
||||
user: 'Felhasználó'
|
||||
},
|
||||
users: {
|
||||
title: 'Felhasználók',
|
||||
create: 'Felhasználó létrehozása',
|
||||
enabled: 'Engedélyezve',
|
||||
disabled: 'Letiltva',
|
||||
deviceOwner: 'Eszköz tulajdonosa',
|
||||
resetPassword: 'Jelszó visszaállítása',
|
||||
delete: 'Törlés',
|
||||
deleteConfirm: 'Törli ezt a felhasználót és visszavonja az összes munkamenetét?',
|
||||
created: 'Felhasználó létrehozva',
|
||||
deleted: 'Felhasználó törölve',
|
||||
passwordUpdated: 'Jelszó frissítve',
|
||||
loadFailed: 'A felhasználókat nem sikerült betölteni',
|
||||
saveFailed: 'A felhasználót nem sikerült menteni',
|
||||
deleteFailed: 'A felhasználót nem sikerült törölni',
|
||||
editPermissions: 'Engedélyek',
|
||||
mcpLossTitle: 'MCP-hozzáférés visszavonása',
|
||||
mcpLossDesc:
|
||||
'{{name}} elveszíti az MCP-hozzáférést. Az MCP API-kulcsot az egész eszköz közösen használja, így egy másolata az újragenerálásig működik. Az újragenerálás minden MCP-klienst lecsatlakoztat, amíg meg nem kapják az új kulcsot.',
|
||||
mcpLossRotate: 'MCP API-kulcs újragenerálása',
|
||||
mcpLossKeep:
|
||||
'A jelenlegi kulcs érvényes marad, és akinél másolat van, továbbra is vezérelheti a billentyűzetet és az egeret. Később az MCP-beállításokban újragenerálhatja.',
|
||||
mcpKeyRotated: 'MCP API-kulcs újragenerálva. Az MCP-klienseknek az új kulcs kell.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Engedélyek',
|
||||
editTitle: 'Engedélyek szerkesztése',
|
||||
save: 'Mentés',
|
||||
saved: 'Mentve',
|
||||
close: 'Bezárás',
|
||||
groupConsole: 'Konzol',
|
||||
groupTarget: 'Célgép',
|
||||
groupLibrary: 'Médiatár',
|
||||
groupHost: 'Ez az eszköz',
|
||||
groupServices: 'Szolgáltatások',
|
||||
consoleView: 'Képernyő megtekintése',
|
||||
consoleControl: 'Billentyűzet és egér',
|
||||
power: 'Tápellátás és ébresztés',
|
||||
mediumAttach: 'Médium csatolása',
|
||||
libraryRead: 'Médiatár böngészése',
|
||||
libraryWrite: 'Médiatár kezelése',
|
||||
terminal: 'Terminál és szkriptek',
|
||||
device: 'Eszközbeállítások',
|
||||
network: 'Hálózati beállítások',
|
||||
update: 'Rendszerfrissítés',
|
||||
mcp: 'MCP szolgáltatás',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI-asszisztens',
|
||||
effectConsoleView:
|
||||
'Megjeleníti a célgép képernyőjét, és beállítja a minden néző által közösen használt videómódot, felbontást, képsebességet és minőséget.',
|
||||
effectConsoleControl: 'Billentyűzet- és egérbevitelt küld a célgépnek.',
|
||||
effectPower:
|
||||
'Be- és kikapcsolja, valamint újraindítja a célgépet, és kezeli a Wake-on-LAN eszközlistát.',
|
||||
effectMediumAttach:
|
||||
'Csatolja és kiadja a médiumokat a célgépen, és a megosztott partíción keresztül fájlokat cserél vele.',
|
||||
effectLibraryRead: 'Listázza és letölti a lemezképeket és a bennük lévő fájlokat.',
|
||||
effectLibraryWrite:
|
||||
'Feltölt, létrehoz és töröl lemezképeket, valamint módosítja a bennük lévő fájlokat.',
|
||||
effectTerminal: 'Root shellt ad ezen az eszközön, a webes munkamenet megkerülésével.',
|
||||
effectDevice:
|
||||
'Lehetővé teszi az SSH bekapcsolását (ez is root), az eszköz újraindítását, a nyers adatpartíció átadását a célgépnek, valamint az OLED, HDMI, mDNS, memória és swap beállításainak módosítását.',
|
||||
effectNetwork:
|
||||
'Módosítja a Wi-Fi, DNS és TLS beállításokat, ami leválaszthatja ezt az eszközt a hálózatról.',
|
||||
effectUpdate: 'Firmware-t és alkalmazáscsomagokat telepít.',
|
||||
effectMcp:
|
||||
'Eszközszintű API-kulcsot ad ki, amely a webes munkameneten kívül vezérli a billentyűzetet, az egeret és a képernyőképeket; egy másolat az engedély visszavonása után is működik, hacsak nem generálják újra a kulcsot, amit az engedély eltávolításakor felajánlunk.',
|
||||
effectTailscale: 'Csatlakoztatja ezt az eszközt egy VPN-hálózathoz.',
|
||||
effectAi:
|
||||
'Olyan helyi felületen keresztül vezérli az eszközt, amelyet a többi engedély nem korlátoz, így gyakorlatilag teljes eszközvezérlést jelent.',
|
||||
fullControlWarning: 'Ezek az engedélyek teljes eszközvezérlést jelentenek:',
|
||||
emptyWarning: 'Ennek a fióknak nem lesz semmilyen funkciója, csak bejelentkezni tud.',
|
||||
impliedBy: 'A(z) {{name}} tartalmazza',
|
||||
grantCount_one: '{{count}} engedély',
|
||||
grantCount: '{{count}} engedély',
|
||||
adminAll: 'Az adminisztrátorok minden engedéllyel rendelkeznek',
|
||||
catalogFailed: 'Az engedélylista nem tölthető be. Zárja be az ablakot, és próbálja újra.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const id = {
|
||||
login: 'Masuk',
|
||||
changePassword: 'Ubah Sandi',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Gambar dan berbagi file'
|
||||
},
|
||||
auth: {
|
||||
login: 'Masuk',
|
||||
@@ -39,7 +40,10 @@ const id = {
|
||||
change2: 'Kata sandi login web',
|
||||
change3: 'Kata sandi root sistem (kata sandi login SSH)',
|
||||
change4: 'Untuk mengatur ulang kata sandi, tekan dan tahan tombol BOOT pada NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Kata sandi saat ini',
|
||||
passwordLength: 'Kata sandi harus antara 8 dan 72 karakter',
|
||||
invalidCurrentPassword: 'Kata sandi saat ini salah'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const id = {
|
||||
verifyBtn: 'Verifikasi'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Hanya lihat',
|
||||
readOnlyTip:
|
||||
'Akun ini tidak dapat mengirim input keyboard atau tikus ke mesin target, sehingga konsol hanya menampilkan layar.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Akun ini tidak memiliki akses konsol',
|
||||
description:
|
||||
'Akun ini diberi kemampuan lain. Buka salah satu halaman yang dapat dipakai, atau minta administrator memberikan akses konsol.',
|
||||
nothing: 'Akun ini belum memiliki izin apa pun. Minta administrator memberikannya.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Layar',
|
||||
@@ -214,33 +229,43 @@ const id = {
|
||||
disable: 'Nonaktifkan mode HID-Hanya'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Gambar',
|
||||
loading: 'Memuat...',
|
||||
empty: 'Tidak ada yang ditemukan',
|
||||
storage: {
|
||||
title: 'Gambar dan berbagi file',
|
||||
empty: 'Belum ada gambar. Unggah ISO atau IMG, atau salin satu ke disk dari komputer target.',
|
||||
notAttached: 'Tidak ada yang terpasang',
|
||||
attached: 'Terpasang: {{name}}',
|
||||
eject: 'Keluarkan',
|
||||
mount: 'Pasang',
|
||||
mountMode: 'Mode pemasangan',
|
||||
mountFailed: 'Pemasangan Gagal',
|
||||
mountDesc:
|
||||
'Di beberapa sistem, perlu mengeluarkan disk virtual pada host jarak jauh sebelum memasang gambar.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Pelepasan gagal',
|
||||
unmountDesc:
|
||||
'Pada beberapa sistem, Anda perlu mengeluarkan secara manual dari host jarak jauh sebelum melepas gambar.',
|
||||
refresh: 'Segarkan daftar gambar',
|
||||
attention: 'Perhatian',
|
||||
deleteConfirm: 'Apakah Anda yakin ingin menghapus gambar ini?',
|
||||
okBtn: 'Ya',
|
||||
cancelBtn: 'Tidak',
|
||||
mountModeDisk: 'Penyimpanan massal',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Buka pengelola file',
|
||||
refresh: 'Segarkan',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus media',
|
||||
deleteConfirm: 'Hapus {{name}}? File di dalamnya akan hilang.',
|
||||
downloadImage: 'Unduh gambar',
|
||||
typePartition: 'File bersama',
|
||||
volumeLabel: 'Disk yang dapat dilepas',
|
||||
groups: {
|
||||
share: 'Berbagi file',
|
||||
images: 'Gambar'
|
||||
},
|
||||
failed: 'Operasi gagal',
|
||||
hidOnly: 'Mode hanya HID: disk virtual tidak tersedia',
|
||||
disabled: 'Disk virtual dinonaktifkan (Pengaturan - Perangkat)',
|
||||
force: {
|
||||
title: 'Ambil kembali media dengan paksa?',
|
||||
desc: 'Komputer target masih menggunakan media ini. Mengambilnya kembali akan menghitung ulang perangkat USB dan dapat kehilangan apa pun yang belum ditulisnya ke disk.',
|
||||
menuHint:
|
||||
'Komputer target sedang menggunakan media ini. Buka pengelola file untuk mengambilnya kembali dengan paksa.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Cara mengunggah',
|
||||
usb1: 'Hubungkan NanoKVM ke komputer Anda melalui USB.',
|
||||
usb2: 'Pastikan disk virtual telah terpasang (Pengaturan - Disk Virtual).',
|
||||
usb2: 'Di halaman ini, pasang berkas bersama dalam mode penyimpanan massal.',
|
||||
usb3: 'Buka disk virtual di komputer Anda dan salin file gambar ke direktori root disk virtual.',
|
||||
usb4: 'Keluarkan disk setelah penyalinan selesai: image kemudian muncul di daftar.',
|
||||
scp1: 'Pastikan NanoKVM dan komputer Anda berada di jaringan lokal yang sama.',
|
||||
scp2: 'Buka terminal di komputer Anda dan gunakan perintah SCP untuk mengunggah file gambar ke direktori /data di NanoKVM.',
|
||||
scp3: 'Contoh: scp jalur-gambar-anda root@ip-nanokvm-anda:/data',
|
||||
@@ -250,7 +275,70 @@ const id = {
|
||||
tf3: 'Masukkan Kartu TF ke pembaca kartu dan hubungkan ke komputer Anda.',
|
||||
tf4: 'Salin berkas gambar ke direktori /data pada Kartu TF.',
|
||||
tf5: 'Masukkan Kartu TF ke dalam NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Memproses {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Aktifkan disk virtual?',
|
||||
desc: 'Untuk memasang media diperlukan disk virtual, yang saat ini dimatikan. Aktifkan sekarang? Papan ketik dan tetikus komputer target akan terputus sebentar.',
|
||||
menuHint: 'Disk virtual dinonaktifkan. Aktifkan di Pengaturan - Perangkat.',
|
||||
askDevice:
|
||||
'Disk virtual nonaktif. Akun dengan izin perangkat harus mengaktifkannya terlebih dahulu.'
|
||||
},
|
||||
add: {
|
||||
title: 'Tambah media',
|
||||
upload: 'Unggah ISO atau IMG',
|
||||
fromUrl: 'Unduh dari URL',
|
||||
url: 'URL gambar',
|
||||
urlRequired: 'Masukkan URL',
|
||||
onlyImages: 'Hanya file .iso dan .img yang dapat dipasang',
|
||||
dropHint: 'Seret ISO atau IMG ke sini, atau klik untuk memilih file',
|
||||
cdromOnlyIso: 'CD-ROM hanya dapat berisi citra ISO 9660',
|
||||
uploadFailed: 'Unggahan gagal',
|
||||
downloadFailed: 'Unduhan gagal',
|
||||
uploading: 'Mengunggah {{name}}',
|
||||
downloading: 'Mengunduh {{name}}',
|
||||
done: '{{name}} siap',
|
||||
checksumFailed: 'Image tidak cocok dengan checksum SHA-256.',
|
||||
blocked: 'Komputer target sedang menggunakan disk',
|
||||
copyHint:
|
||||
'Anda juga dapat menyalin citra ke partisi data lewat SCP, atau dari komputer target.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Pilih media untuk melihat file di dalamnya.',
|
||||
reasonAttached: 'Media ini terpasang ke komputer target.',
|
||||
reasonAttachedHint:
|
||||
'Keluarkan dulu: komputer target harus melepaskan disk sebelum filenya dapat dibaca.',
|
||||
reasonNotBrowsable:
|
||||
'Media ini berisi sistem berkas yang tidak dapat dibuka pengelola file.',
|
||||
reasonNoLoop: 'Perangkat ini tidak dapat memasang image disk (tanpa dukungan loop).',
|
||||
reasonISOImage:
|
||||
'Citra ISO 9660 tidak dapat dijelajahi di sini. Citra tetap dapat dipasang ke komputer target.',
|
||||
empty: 'Folder ini kosong',
|
||||
noMatch: 'Tidak ada file yang cocok dengan pencarian.',
|
||||
free: '{{size}} kosong',
|
||||
search: 'Cari di folder ini',
|
||||
sort: {
|
||||
name: 'Nama',
|
||||
size: 'Ukuran',
|
||||
type: 'Jenis',
|
||||
asc: 'Naik',
|
||||
desc: 'Turun'
|
||||
},
|
||||
upload: 'Unggah file ke media',
|
||||
deleteFolderTitle: 'Hapus folder',
|
||||
deleteFolderConfirm:
|
||||
'Hapus folder {{name}} beserta seluruh isinya? Tindakan ini tidak dapat dibatalkan.',
|
||||
uploading: 'Mengunggah…',
|
||||
uploadFailed: 'Unggahan gagal',
|
||||
download: 'Unduh',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus file',
|
||||
deleteConfirm: 'Hapus {{name}} dari media?'
|
||||
},
|
||||
yes: 'Ya',
|
||||
no: 'Tidak'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -292,21 +380,11 @@ const id = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Pengunduh Gambar',
|
||||
input: 'Silakan masukkan gambar jarak jauh URL',
|
||||
ok: 'Ok',
|
||||
disabled: 'Partisi /data adalah RO, jadi kami tidak dapat mengunduh gambarnya',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Letakkan file di sini atau klik untuk memilih',
|
||||
inputfile: 'Silakan masukkan File gambar',
|
||||
NoISO: 'Tidak ada ISO',
|
||||
disabled:
|
||||
'Partisi data bersifat hanya-baca selama komputer target memakainya: citra baru tidak dapat ditulis, file yang ada tetap dapat diunduh.',
|
||||
sha256: 'SHA-256 (opsional)',
|
||||
sha256Placeholder: 'Masukkan checksum SHA-256 64 karakter',
|
||||
invalidSHA256: 'SHA-256 harus berupa string heksadesimal 64 karakter',
|
||||
failed: 'Unduhan gagal',
|
||||
success: 'Unduhan berhasil',
|
||||
checksumFailed: 'Unduhan gagal: verifikasi SHA-256 gagal',
|
||||
cancel: 'Batal',
|
||||
cancelFailed: 'Gagal membatalkan unduhan'
|
||||
},
|
||||
@@ -455,16 +533,18 @@ const id = {
|
||||
hidOnlyDesc: 'Berhenti meniru perangkat virtual, hanya mempertahankan kontrol dasar HID',
|
||||
disk: 'Disk virtual',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Jaringan virtual',
|
||||
networkDesc: 'Pasang kartu jaringan virtual pada host jarak jauh',
|
||||
reboot: 'Mulai ulang',
|
||||
rebootDesc: 'Apakah Anda yakin ingin me-reboot NanoKVM?',
|
||||
okBtn: 'Ya',
|
||||
cancelBtn: 'Tidak'
|
||||
cancelBtn: 'Tidak',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Perhatian',
|
||||
updateFailed: 'Gagal memperbarui perangkat virtual'
|
||||
},
|
||||
network: {
|
||||
title: 'Jaringan',
|
||||
@@ -603,7 +683,89 @@ const id = {
|
||||
logoutBtn: 'Keluar',
|
||||
logoutDesc: 'Apakah Anda yakin ingin logout?',
|
||||
okBtn: 'Ya',
|
||||
cancelBtn: 'Tidak'
|
||||
cancelBtn: 'Tidak',
|
||||
role: 'Peran',
|
||||
roles: {
|
||||
admin: 'Administrator',
|
||||
user: 'Pengguna'
|
||||
},
|
||||
users: {
|
||||
title: 'Pengguna',
|
||||
create: 'Buat pengguna',
|
||||
enabled: 'Diaktifkan',
|
||||
disabled: 'Dinonaktifkan',
|
||||
deviceOwner: 'Pemilik perangkat',
|
||||
resetPassword: 'Setel ulang kata sandi',
|
||||
delete: 'Hapus',
|
||||
deleteConfirm: 'Hapus pengguna ini dan cabut semua sesinya?',
|
||||
created: 'Pengguna dibuat',
|
||||
deleted: 'Pengguna dihapus',
|
||||
passwordUpdated: 'Kata sandi diperbarui',
|
||||
loadFailed: 'Gagal memuat pengguna',
|
||||
saveFailed: 'Gagal menyimpan pengguna',
|
||||
deleteFailed: 'Gagal menghapus pengguna',
|
||||
editPermissions: 'Izin',
|
||||
mcpLossTitle: 'Cabut akses MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} akan kehilangan akses MCP. Kunci API MCP dipakai bersama oleh seluruh perangkat, jadi salinannya tetap berlaku sampai kunci dibuat ulang. Membuat ulang akan memutus semua klien MCP sampai mereka menerima kunci baru.',
|
||||
mcpLossRotate: 'Buat ulang kunci API MCP',
|
||||
mcpLossKeep:
|
||||
'Kunci saat ini tetap berlaku, dan siapa pun yang memiliki salinannya masih dapat mengendalikan keyboard dan mouse. Anda dapat membuatnya ulang nanti di pengaturan MCP.',
|
||||
mcpKeyRotated: 'Kunci API MCP dibuat ulang. Klien MCP memerlukan kunci baru.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Izin',
|
||||
editTitle: 'Edit izin',
|
||||
save: 'Simpan',
|
||||
saved: 'Tersimpan',
|
||||
close: 'Tutup',
|
||||
groupConsole: 'Konsol',
|
||||
groupTarget: 'Mesin target',
|
||||
groupLibrary: 'Pustaka media',
|
||||
groupHost: 'Perangkat ini',
|
||||
groupServices: 'Layanan',
|
||||
consoleView: 'Lihat layar',
|
||||
consoleControl: 'Keyboard dan mouse',
|
||||
power: 'Daya dan bangunkan',
|
||||
mediumAttach: 'Pasang media',
|
||||
libraryRead: 'Jelajahi pustaka',
|
||||
libraryWrite: 'Kelola pustaka',
|
||||
terminal: 'Terminal dan skrip',
|
||||
device: 'Pengaturan perangkat',
|
||||
network: 'Pengaturan jaringan',
|
||||
update: 'Pembaruan sistem',
|
||||
mcp: 'Layanan MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Asisten AI',
|
||||
effectConsoleView:
|
||||
'Menampilkan layar target dan mengatur mode video, resolusi, laju bingkai, serta kualitas yang dipakai bersama semua penonton.',
|
||||
effectConsoleControl: 'Mengirim masukan keyboard dan mouse ke mesin target.',
|
||||
effectPower:
|
||||
'Menyalakan, mematikan, dan memulai ulang mesin target, serta mengelola daftar perangkat Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Memasang dan melepas media pada mesin target, serta bertukar berkas dengannya melalui partisi bersama.',
|
||||
effectLibraryRead: 'Menampilkan dan mengunduh image beserta berkas di dalamnya.',
|
||||
effectLibraryWrite:
|
||||
'Mengunggah, membuat, dan menghapus image, serta mengubah berkas di dalamnya.',
|
||||
effectTerminal: 'Memberi shell root di perangkat ini tanpa melalui sesi web.',
|
||||
effectDevice:
|
||||
'Memungkinkan mengaktifkan SSH (juga root), memulai ulang perangkat, menyerahkan partisi data mentah ke mesin target, dan mengubah pengaturan OLED, HDMI, mDNS, memori, serta swap.',
|
||||
effectNetwork:
|
||||
'Mengubah Wi-Fi, DNS, dan TLS, yang dapat memutus perangkat ini dari jaringan.',
|
||||
effectUpdate: 'Memasang firmware dan paket aplikasi.',
|
||||
effectMcp:
|
||||
'Menerbitkan kunci API untuk seluruh perangkat yang mengendalikan keyboard, mouse, dan tangkapan layar di luar sesi web; salinan kunci tetap berlaku setelah izin dicabut kecuali kunci dibuat ulang, yang ditawarkan saat izin dihapus.',
|
||||
effectTailscale: 'Menghubungkan perangkat ini ke jaringan VPN.',
|
||||
effectAi:
|
||||
'Mengendalikan perangkat melalui antarmuka lokal yang tidak dibatasi izin lain, sehingga setara dengan kendali penuh atas perangkat.',
|
||||
fullControlWarning: 'Izin ini setara dengan kendali penuh atas perangkat:',
|
||||
emptyWarning: 'Akun ini tidak akan memiliki fitur apa pun dan hanya bisa masuk.',
|
||||
impliedBy: 'Termasuk oleh {{name}}',
|
||||
grantCount_one: '{{count}} izin',
|
||||
grantCount: '{{count}} izin',
|
||||
adminAll: 'Administrator memiliki semua izin',
|
||||
catalogFailed: 'Daftar izin gagal dimuat. Tutup dialog ini dan coba lagi.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const it = {
|
||||
login: 'Accesso',
|
||||
changePassword: 'Cambia Password',
|
||||
terminal: 'Terminale',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Immagini e condivisione file'
|
||||
},
|
||||
auth: {
|
||||
login: 'Accesso',
|
||||
@@ -40,7 +41,10 @@ const it = {
|
||||
change2: 'Password di accesso web',
|
||||
change3: 'Password root di sistema (password di accesso SSH)',
|
||||
change4: 'Per reimpostare le password, tieni premuto il pulsante BOOT sul NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Password attuale',
|
||||
passwordLength: 'La password deve contenere da 8 a 72 caratteri',
|
||||
invalidCurrentPassword: 'La password attuale non è corretta'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const it = {
|
||||
verifyBtn: 'Verifica'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Solo visualizzazione',
|
||||
readOnlyTip:
|
||||
'Questo account non può inviare input da tastiera o mouse al computer di destinazione, quindi la console mostra solo lo schermo.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Questo account non ha accesso alla console',
|
||||
description:
|
||||
'Gli sono state concesse altre capacità. Apri una delle pagine che può usare o chiedi a un amministratore l’accesso alla console.',
|
||||
nothing: 'Questo account non ha ancora permessi. Chiedi a un amministratore di concederne.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Scala',
|
||||
title: 'Schermo',
|
||||
@@ -216,33 +231,44 @@ const it = {
|
||||
disable: 'Disabilita la modalità HID-Solo'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Immagini',
|
||||
loading: 'Caricamento...',
|
||||
empty: 'Nessun risultato',
|
||||
storage: {
|
||||
title: 'Immagini e condivisione file',
|
||||
empty:
|
||||
'Nessuna immagine. Carica un file ISO o IMG oppure copiane uno sul disco dalla macchina remota.',
|
||||
notAttached: 'Niente montato',
|
||||
attached: 'Montato: {{name}}',
|
||||
eject: 'Espelli',
|
||||
mount: 'Monta',
|
||||
mountMode: 'Modalità di montaggio',
|
||||
mountFailed: 'Montaggio immagine fallito',
|
||||
mountDesc:
|
||||
"In alcuni sistemi, è necessario espellere il disco virtuale sull'host remoto prima di montare l'immagine.",
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Smontaggio non riuscito',
|
||||
unmountDesc:
|
||||
"Su alcuni sistemi, è necessario espellere manualmente l'host remoto prima di smontare l'immagine.",
|
||||
refresh: "Aggiorna l'elenco delle immagini",
|
||||
attention: 'Attenzione',
|
||||
deleteConfirm: 'Sei sicuro di voler eliminare questa immagine?',
|
||||
okBtn: 'Sì',
|
||||
cancelBtn: 'No',
|
||||
mountModeDisk: 'Archiviazione di massa',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Apri il gestore file',
|
||||
refresh: 'Aggiorna',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina supporto',
|
||||
deleteConfirm: 'Eliminare {{name}}? I file che contiene andranno persi.',
|
||||
downloadImage: "Scarica l'immagine",
|
||||
typePartition: 'File condivisi',
|
||||
volumeLabel: 'Disco rimovibile',
|
||||
groups: {
|
||||
share: 'Condivisione file',
|
||||
images: 'Immagini'
|
||||
},
|
||||
failed: 'Operazione non riuscita',
|
||||
hidOnly: 'Modalità solo HID: il disco virtuale non è disponibile',
|
||||
disabled: 'Il disco virtuale è disattivato (Impostazioni - Dispositivo)',
|
||||
force: {
|
||||
title: 'Riprendere il supporto con la forza?',
|
||||
desc: 'La macchina remota sta ancora usando questo supporto. Riprenderlo rienumera il dispositivo USB e può perdere tutto ciò che non ha ancora scritto sul disco.',
|
||||
menuHint:
|
||||
'La macchina remota sta usando questo supporto. Apri il gestore file per riprenderlo con la forza.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Come caricare',
|
||||
usb1: 'Collega il NanoKVM al tuo computer tramite USB.',
|
||||
usb2: 'Assicurati che la Virtual Disk sia montata (Impostazioni - Virtual Disk).',
|
||||
usb2: 'In questa pagina, monta i file condivisi in modalità archiviazione di massa.',
|
||||
usb3: 'Apri il disk virtuale sul tuo computer e copia il file immagine nella directory principale del disk.',
|
||||
usb4: "Espelli il disco al termine della copia: l'immagine comparirà nell'elenco.",
|
||||
scp1: 'Assicurati che il NanoKVM e il tuo computer siano sulla stessa rete locale.',
|
||||
scp2: 'Apri un terminale sul tuo computer e usa il comando SCP per caricare il file immagine nella directory /data del NanoKVM.',
|
||||
scp3: 'Esempio: scp il-tuo-percorso-immagine root@il-tuo-ip-nanokvm:/data',
|
||||
@@ -252,7 +278,70 @@ const it = {
|
||||
tf3: 'Inserisci la scheda TF in un lettore di schede e collegala al tuo computer.',
|
||||
tf4: 'Copia il file immagine nella directory /data sulla scheda TF.',
|
||||
tf5: 'Inserisci la scheda TF nel NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Elaborazione di {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Attivare il disco virtuale?',
|
||||
desc: 'Per montare un supporto occorre il disco virtuale, che al momento è disattivato. Attivarlo ora? La tastiera e il mouse della macchina remota si disconnetteranno per un istante.',
|
||||
menuHint: 'Il disco virtuale è disattivato. Attivalo in Impostazioni - Dispositivo.',
|
||||
askDevice:
|
||||
"Il disco virtuale è disattivato. Un account con l'autorizzazione dispositivo deve attivarlo prima."
|
||||
},
|
||||
add: {
|
||||
title: 'Aggiungi supporto',
|
||||
upload: 'Carica un file ISO o IMG',
|
||||
fromUrl: 'Scarica da un URL',
|
||||
url: "URL dell'immagine",
|
||||
urlRequired: 'Inserisci un URL',
|
||||
onlyImages: 'Si possono montare solo file .iso e .img',
|
||||
dropHint: 'Trascina qui un file ISO o IMG, oppure fai clic per scegliere un file',
|
||||
cdromOnlyIso: 'Un CD-ROM può contenere solo un’immagine ISO 9660',
|
||||
uploadFailed: 'Caricamento non riuscito',
|
||||
downloadFailed: 'Download non riuscito',
|
||||
uploading: 'Caricamento di {{name}}',
|
||||
downloading: 'Download di {{name}}',
|
||||
done: '{{name}} è pronto',
|
||||
checksumFailed: "L'immagine non corrisponde al checksum SHA-256.",
|
||||
blocked: 'La macchina remota sta usando il disco',
|
||||
copyHint:
|
||||
"Puoi anche copiare un'immagine nella partizione dati via SCP, o dalla macchina remota."
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Seleziona un supporto per sfogliare i file che contiene.',
|
||||
reasonAttached: 'Questo supporto è collegato alla macchina remota.',
|
||||
reasonAttachedHint:
|
||||
'Espellilo prima: la macchina remota deve rilasciare il disco prima che i suoi file possano essere letti.',
|
||||
reasonNotBrowsable:
|
||||
'Questo supporto contiene un filesystem che il gestore file non può aprire.',
|
||||
reasonNoLoop: 'Questo dispositivo non può montare immagini disco (nessun supporto loop).',
|
||||
reasonISOImage:
|
||||
"Un'immagine ISO 9660 non può essere sfogliata qui. Può comunque essere montata sulla macchina remota.",
|
||||
empty: 'Questa cartella è vuota',
|
||||
noMatch: 'Nessun file corrisponde alla ricerca.',
|
||||
free: '{{size}} liberi',
|
||||
search: 'Cerca in questa cartella',
|
||||
sort: {
|
||||
name: 'Nome',
|
||||
size: 'Dimensione',
|
||||
type: 'Tipo',
|
||||
asc: 'Crescente',
|
||||
desc: 'Decrescente'
|
||||
},
|
||||
upload: 'Carica un file nel supporto',
|
||||
deleteFolderTitle: 'Elimina cartella',
|
||||
deleteFolderConfirm:
|
||||
"Eliminare la cartella {{name}} e tutto il suo contenuto? L'operazione non può essere annullata.",
|
||||
uploading: 'Caricamento…',
|
||||
uploadFailed: 'Caricamento non riuscito',
|
||||
download: 'Scarica',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Elimina file',
|
||||
deleteConfirm: 'Eliminare {{name}} dal supporto?'
|
||||
},
|
||||
yes: 'Sì',
|
||||
no: 'No'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -294,21 +383,11 @@ const it = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Scaricatore di immagini',
|
||||
input: "Inserisci un'immagine remota URL",
|
||||
ok: 'Ok',
|
||||
disabled: "La partizione /data è RO, quindi non possiamo scaricare l'immagine",
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Rilascia il file qui o fai clic per selezionarlo',
|
||||
inputfile: 'Inserisci il file immagine',
|
||||
NoISO: 'Nessuna ISO',
|
||||
disabled:
|
||||
'La partizione dati è in sola lettura mentre la usa la macchina remota: non si possono scrivere nuove immagini, ma i file esistenti si scaricano comunque.',
|
||||
sha256: 'SHA-256 (facoltativo)',
|
||||
sha256Placeholder: 'Inserisci un checksum SHA-256 di 64 caratteri',
|
||||
invalidSHA256: 'SHA-256 deve essere una stringa esadecimale di 64 caratteri',
|
||||
failed: 'Download non riuscito',
|
||||
success: 'Download riuscito',
|
||||
checksumFailed: 'Download non riuscito: verifica SHA-256 non riuscita',
|
||||
cancel: 'Annulla',
|
||||
cancelFailed: 'Impossibile annullare il download'
|
||||
},
|
||||
@@ -460,16 +539,18 @@ const it = {
|
||||
'Smette di emulare i dispositivi virtuali, mantenendo solo il controllo di base HID',
|
||||
disk: 'Disco virtuale',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Rete virtuale',
|
||||
networkDesc: 'Monta la scheda di rete virtuale sull’host remoto',
|
||||
reboot: 'Riavvia',
|
||||
rebootDesc: 'Sei sicuro di voler riavviare NanoKVM?',
|
||||
okBtn: 'Sì',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Attenzione',
|
||||
updateFailed: 'Aggiornamento del dispositivo virtuale non riuscito'
|
||||
},
|
||||
network: {
|
||||
title: 'Rete',
|
||||
@@ -609,7 +690,90 @@ const it = {
|
||||
logoutBtn: 'Esci',
|
||||
logoutDesc: 'Sei sicuro di voler uscire?',
|
||||
okBtn: 'Sì',
|
||||
cancelBtn: 'No'
|
||||
cancelBtn: 'No',
|
||||
role: 'Ruolo',
|
||||
roles: {
|
||||
admin: 'Amministratore',
|
||||
user: 'Utente'
|
||||
},
|
||||
users: {
|
||||
title: 'Utenti',
|
||||
create: 'Crea utente',
|
||||
enabled: 'Attivato',
|
||||
disabled: 'Disattivato',
|
||||
deviceOwner: 'Proprietario del dispositivo',
|
||||
resetPassword: 'Reimposta password',
|
||||
delete: 'Elimina',
|
||||
deleteConfirm: 'Eliminare questo utente e revocare tutte le sue sessioni?',
|
||||
created: 'Utente creato',
|
||||
deleted: 'Utente eliminato',
|
||||
passwordUpdated: 'Password aggiornata',
|
||||
loadFailed: 'Impossibile caricare gli utenti',
|
||||
saveFailed: "Impossibile salvare l'utente",
|
||||
deleteFailed: "Impossibile eliminare l'utente",
|
||||
editPermissions: 'Autorizzazioni',
|
||||
mcpLossTitle: 'Revoca accesso MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} perderà l’accesso MCP. La chiave API MCP è condivisa da tutto il dispositivo, quindi una copia continua a funzionare finché la chiave non viene rigenerata. Rigenerarla disconnette tutti i client MCP finché non ricevono la nuova chiave.',
|
||||
mcpLossRotate: 'Rigenera la chiave API MCP',
|
||||
mcpLossKeep:
|
||||
'La chiave attuale resta valida e chi ne ha una copia può ancora controllare tastiera e mouse. Potrai rigenerarla in seguito nelle impostazioni MCP.',
|
||||
mcpKeyRotated: 'Chiave API MCP rigenerata. I client MCP hanno bisogno della nuova chiave.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Permessi',
|
||||
editTitle: 'Modifica i permessi',
|
||||
save: 'Salva',
|
||||
saved: 'Salvato',
|
||||
close: 'Chiudi',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Macchina di destinazione',
|
||||
groupLibrary: 'Libreria dei supporti',
|
||||
groupHost: 'Questo dispositivo',
|
||||
groupServices: 'Servizi',
|
||||
consoleView: 'Vedi lo schermo',
|
||||
consoleControl: 'Tastiera e mouse',
|
||||
power: 'Alimentazione e risveglio',
|
||||
mediumAttach: 'Collega un supporto',
|
||||
libraryRead: 'Sfoglia la libreria',
|
||||
libraryWrite: 'Gestisci la libreria',
|
||||
terminal: 'Terminale e script',
|
||||
device: 'Impostazioni dispositivo',
|
||||
network: 'Impostazioni di rete',
|
||||
update: 'Aggiornamento di sistema',
|
||||
mcp: 'Servizio MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Assistente IA',
|
||||
effectConsoleView:
|
||||
'Mostra lo schermo di destinazione e imposta modalità video, risoluzione, frame rate e qualità condivisi da tutti gli spettatori.',
|
||||
effectConsoleControl: 'Invia input di tastiera e mouse alla macchina di destinazione.',
|
||||
effectPower:
|
||||
'Accende, spegne e riavvia la macchina di destinazione e gestisce l’elenco dei dispositivi Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Monta ed espelle supporti sulla macchina di destinazione e scambia file con essa tramite la partizione condivisa.',
|
||||
effectLibraryRead: 'Elenca e scarica le immagini e i file che contengono.',
|
||||
effectLibraryWrite: 'Carica, crea ed elimina immagini e modifica i file che contengono.',
|
||||
effectTerminal:
|
||||
'Fornisce una shell root su questo dispositivo, senza passare dalla sessione web.',
|
||||
effectDevice:
|
||||
'Consente di attivare SSH (anch’esso root), riavviare il dispositivo, consegnare la partizione dati grezza alla macchina di destinazione e modificare OLED, HDMI, mDNS, memoria e swap.',
|
||||
effectNetwork:
|
||||
'Modifica Wi-Fi, DNS e TLS, il che può isolare questo dispositivo dalla rete.',
|
||||
effectUpdate: 'Installa firmware e pacchetti applicativi.',
|
||||
effectMcp:
|
||||
'Emette una chiave API valida per tutto il dispositivo che pilota tastiera, mouse e screenshot fuori dalla sessione web; una copia continua a funzionare dopo la revoca se la chiave non viene rigenerata, cosa proposta quando si rimuove il permesso.',
|
||||
effectTailscale: 'Unisce questo dispositivo a una rete mesh VPN.',
|
||||
effectAi:
|
||||
'Pilota il dispositivo tramite un’interfaccia locale che gli altri permessi non limitano, quindi equivale al controllo totale del dispositivo.',
|
||||
fullControlWarning: 'Questi permessi equivalgono al controllo totale del dispositivo:',
|
||||
emptyWarning: 'Questo account non avrà alcuna funzione e potrà solo accedere.',
|
||||
impliedBy: 'Incluso da {{name}}',
|
||||
grantCount_one: '{{count}} permesso',
|
||||
grantCount: '{{count}} permessi',
|
||||
adminAll: 'Gli amministratori hanno tutti i permessi',
|
||||
catalogFailed:
|
||||
'Impossibile caricare l’elenco dei permessi. Chiudi questa finestra e riprova.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const ja = {
|
||||
login: 'ログイン',
|
||||
changePassword: 'パスワード変更',
|
||||
terminal: 'ターミナル',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'イメージとファイル共有'
|
||||
},
|
||||
auth: {
|
||||
login: 'ログイン',
|
||||
@@ -39,7 +40,10 @@ const ja = {
|
||||
change3: 'システム root パスワード(SSH ログインパスワード)',
|
||||
change4:
|
||||
'パスワードを忘れた場合は、NanoKVM の BOOT ボタンを長押ししてパスワードをリセットする必要があります。'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: '現在のパスワード',
|
||||
passwordLength: 'パスワードは 8〜72 文字である必要があります',
|
||||
invalidCurrentPassword: '現在のパスワードが正しくありません'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const ja = {
|
||||
verifyBtn: '確認する'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: '閲覧のみ',
|
||||
readOnlyTip:
|
||||
'このアカウントは対象マシンへキーボードやマウスの入力を行えないため、コンソールは画面の表示のみになります。'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'このアカウントにはコンソール権限がありません',
|
||||
description:
|
||||
'他の機能が付与されています。利用できるページを開くか、管理者にコンソール権限を依頼してください。',
|
||||
nothing: 'このアカウントにはまだ権限がありません。管理者に付与を依頼してください。'
|
||||
},
|
||||
screen: {
|
||||
scale: '倍率',
|
||||
title: '画面',
|
||||
@@ -214,33 +229,44 @@ const ja = {
|
||||
disable: 'HID-Only モードを無効化'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'イメージ',
|
||||
loading: '読み込み中',
|
||||
empty: 'イメージファイルがありません',
|
||||
storage: {
|
||||
title: 'イメージとファイル共有',
|
||||
empty:
|
||||
'イメージはまだありません。ISO または IMG をアップロードするか、ターゲットマシンからディスクにコピーしてください。',
|
||||
notAttached: '何もマウントされていません',
|
||||
attached: 'マウント中: {{name}}',
|
||||
eject: '取り出す',
|
||||
mount: 'マウント',
|
||||
mountMode: 'マウントモード',
|
||||
mountFailed: 'マウントに失敗しました',
|
||||
mountDesc:
|
||||
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
|
||||
diskOnWarn:
|
||||
'イメージをマウントすると仮想ディスクがオフになり、コンピュータから /data にアクセスできなくなります。続行しますか?',
|
||||
diskOffWarn:
|
||||
'仮想ディスクがオフになっているため、イメージのマウントには有効化が必要です。コンピュータの USB デバイスが再接続されます。続行しますか?',
|
||||
diskShared:
|
||||
'仮想ディスクが有効なため、このデバイスの /data は読み取り専用です。イメージを削除またはアップロードするには仮想ディスクを無効にしてください。',
|
||||
unmountFailed: 'アンマウントに失敗しました',
|
||||
unmountDesc:
|
||||
'一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
|
||||
refresh: 'イメージリストを更新',
|
||||
attention: '注意',
|
||||
deleteConfirm: 'このイメージを削除してもよろしいですか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ',
|
||||
mountModeDisk: 'マスストレージ',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'ファイルマネージャーを開く',
|
||||
refresh: '更新',
|
||||
delete: '削除',
|
||||
deleteTitle: 'メディアを削除',
|
||||
deleteConfirm: '{{name}} を削除しますか?中のファイルは失われます。',
|
||||
downloadImage: 'イメージをダウンロード',
|
||||
typePartition: '共有ファイル',
|
||||
volumeLabel: 'リムーバブルディスク',
|
||||
groups: {
|
||||
share: 'ファイル共有',
|
||||
images: 'イメージ'
|
||||
},
|
||||
failed: '操作に失敗しました',
|
||||
hidOnly: 'HID のみのモード: 仮想ディスクは利用できません',
|
||||
disabled: '仮想ディスクは無効です (設定 - デバイス)',
|
||||
force: {
|
||||
title: 'メディアを強制的に取り戻しますか?',
|
||||
desc: 'ターゲットマシンがまだこのメディアを使用しています。取り戻すと USB デバイスが再列挙され、まだディスクに書き込まれていない内容が失われる可能性があります。',
|
||||
menuHint:
|
||||
'ターゲットマシンがこのメディアを使用しています。ファイルマネージャーを開いて強制的に取り戻してください。'
|
||||
},
|
||||
tips: {
|
||||
title: 'アップロード方法',
|
||||
usb1: 'NanoKVM を USB 経由でコンピュータに接続します;',
|
||||
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク);',
|
||||
usb2: 'このページで共有ファイルをマスストレージモードでマウントします。',
|
||||
usb3: 'コンピュータ上で仮想ディスクを開き、イメージファイルを仮想ディスクのルートディレクトリにコピーします。',
|
||||
usb4: 'コピーが終わったらディスクを取り出します。イメージがリストに表示されます。',
|
||||
scp1: 'NanoKVM とコンピュータが同じローカルエリアネットワークに接続されていることを確認します;',
|
||||
scp2: 'コンピュータでターミナルを開き、SCP コマンドを使用してイメージファイルを NanoKVM の /data ディレクトリにアップロードします。',
|
||||
scp3: '例:scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -250,7 +276,70 @@ const ja = {
|
||||
tf3: 'TF カードをカードリーダーに挿入してコンピュータに接続します;',
|
||||
tf4: 'コンピューターから TF カードの /data ディレクトリにイメージファイルをコピーします;',
|
||||
tf5: 'TF カードを NanoKVM に挿入します。'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '{{name}} を処理中'
|
||||
},
|
||||
enableDisk: {
|
||||
title: '仮想ディスクを有効にしますか?',
|
||||
desc: 'メディアをマウントするには仮想ディスクが必要ですが、現在は無効です。今すぐ有効にしますか?ターゲットマシンのキーボードとマウスが一時的に切断されます。',
|
||||
menuHint: '仮想ディスクは無効です。設定 - デバイスで有効にしてください。',
|
||||
askDevice:
|
||||
'仮想ディスクは無効になっています。デバイス権限を持つアカウントで先に有効にしてください。'
|
||||
},
|
||||
add: {
|
||||
title: 'メディアを追加',
|
||||
upload: 'ISO または IMG をアップロード',
|
||||
fromUrl: 'URL からダウンロード',
|
||||
url: 'イメージの URL',
|
||||
urlRequired: 'URL を入力してください',
|
||||
onlyImages: 'マウントできるのは .iso と .img ファイルのみです',
|
||||
dropHint: 'ISO または IMG をここにドロップするか、クリックしてファイルを選択',
|
||||
cdromOnlyIso: 'CD-ROM には ISO 9660 イメージしか載せられません',
|
||||
uploadFailed: 'アップロードに失敗しました',
|
||||
downloadFailed: 'ダウンロードに失敗しました',
|
||||
uploading: '{{name}} をアップロード中',
|
||||
downloading: '{{name}} をダウンロード中',
|
||||
done: '{{name}} の準備ができました',
|
||||
checksumFailed: 'イメージが SHA-256 チェックサムと一致しません。',
|
||||
blocked: 'ターゲットマシンがディスクを使用しています',
|
||||
copyHint:
|
||||
'イメージは SCP でデータパーティションにコピーするか、ターゲットマシンから直接コピーすることもできます。'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'メディアを選択すると中のファイルを閲覧できます。',
|
||||
reasonAttached: 'このメディアはターゲットマシンに接続されています。',
|
||||
reasonAttachedHint:
|
||||
'先に取り出してください。ターゲットマシンがディスクを解放するまでファイルは読み取れません。',
|
||||
reasonNotBrowsable:
|
||||
'このメディアにはファイルマネージャーで開けないファイルシステムが含まれています。',
|
||||
reasonNoLoop: 'このデバイスはディスクイメージをマウントできません (loop サポートなし)。',
|
||||
reasonISOImage:
|
||||
'ISO 9660 イメージの中身はここでは閲覧できません。ターゲットマシンへのマウントは可能です。',
|
||||
empty: 'このフォルダは空です',
|
||||
noMatch: '検索に一致するファイルはありません。',
|
||||
free: '空き {{size}}',
|
||||
search: 'このフォルダを検索',
|
||||
sort: {
|
||||
name: '名前',
|
||||
size: 'サイズ',
|
||||
type: '種類',
|
||||
asc: '昇順',
|
||||
desc: '降順'
|
||||
},
|
||||
upload: 'メディアにファイルをアップロード',
|
||||
deleteFolderTitle: 'フォルダーを削除',
|
||||
deleteFolderConfirm:
|
||||
'フォルダー {{name}} とその中身をすべて削除しますか?この操作は元に戻せません。',
|
||||
uploading: 'アップロード中…',
|
||||
uploadFailed: 'アップロードに失敗しました',
|
||||
download: 'ダウンロード',
|
||||
delete: '削除',
|
||||
deleteTitle: 'ファイルを削除',
|
||||
deleteConfirm: '{{name}} をメディアから削除しますか?'
|
||||
},
|
||||
yes: 'はい',
|
||||
no: 'いいえ'
|
||||
},
|
||||
script: {
|
||||
title: 'スクリプト',
|
||||
@@ -292,22 +381,11 @@ const ja = {
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'イメージダウンローダー',
|
||||
input: 'リモートイメージの URL を入力してください',
|
||||
ok: 'OK',
|
||||
disabled:
|
||||
'/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません',
|
||||
diskShared: '先に仮想ディスクをオフにしてから、イメージのダウンロードを使用してください。',
|
||||
diskOff: '仮想ディスクをオフにする',
|
||||
uploadbox: 'ここにファイルをドロップするか、クリックして選択してください',
|
||||
inputfile: '画像ファイルを入力してください',
|
||||
NoISO: 'ISO なし',
|
||||
'ターゲットマシンが使用している間、データパーティションは読み取り専用です。新しいイメージは書き込めませんが、既存のファイルはダウンロードできます。',
|
||||
sha256: 'SHA-256(任意)',
|
||||
sha256Placeholder: '64 文字の SHA-256 チェックサムを入力してください',
|
||||
invalidSHA256: 'SHA-256 は 64 文字の 16 進数文字列である必要があります',
|
||||
failed: 'ダウンロードに失敗しました',
|
||||
success: 'ダウンロードに成功しました',
|
||||
checksumFailed: 'ダウンロードに失敗しました:SHA-256 検証に失敗しました',
|
||||
cancel: 'キャンセル',
|
||||
cancelFailed: 'ダウンロードのキャンセルに失敗しました'
|
||||
},
|
||||
@@ -458,17 +536,19 @@ const ja = {
|
||||
'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
|
||||
disk: '仮想ディスク',
|
||||
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
|
||||
diskLocked: 'イメージのマウント中はオフです(オンにするとマウントが解除されます)',
|
||||
diskCancelMount:
|
||||
'仮想ディスクを有効にすると、マウント中のイメージは解除され、進行中のイメージのダウンロード/アップロードも中断されます。続行しますか?',
|
||||
diskBreakDownload:
|
||||
'仮想ディスクを有効にすると、イメージのダウンロードが中断されます。続行しますか?',
|
||||
network: '仮想ネットワークカード',
|
||||
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
|
||||
reboot: '再起動',
|
||||
rebootDesc: 'NanoKVM を再起動してもよろしいですか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ'
|
||||
cancelBtn: 'いいえ',
|
||||
diskLocked: 'イメージのマウント中はオフです(オンにするとマウントが解除されます)',
|
||||
diskCancelMount:
|
||||
'仮想ディスクを有効にすると、マウント中のイメージは解除され、進行中のイメージのダウンロード/アップロードも中断されます。続行しますか?',
|
||||
diskBreakDownload:
|
||||
'仮想ディスクを有効にすると、イメージのダウンロードが中断されます。続行しますか?',
|
||||
attention: '注意',
|
||||
updateFailed: '仮想デバイスの更新に失敗しました'
|
||||
},
|
||||
network: {
|
||||
title: 'ネットワーク',
|
||||
@@ -609,7 +689,89 @@ const ja = {
|
||||
logoutBtn: 'ログアウト',
|
||||
logoutDesc: 'ログアウトしてもよろしいですか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ'
|
||||
cancelBtn: 'いいえ',
|
||||
role: '役割',
|
||||
roles: {
|
||||
admin: '管理者',
|
||||
user: 'ユーザー'
|
||||
},
|
||||
users: {
|
||||
title: 'ユーザー',
|
||||
create: 'ユーザーを作成',
|
||||
enabled: '有効',
|
||||
disabled: '無効',
|
||||
deviceOwner: 'デバイスの所有者',
|
||||
resetPassword: 'パスワードをリセット',
|
||||
delete: '削除',
|
||||
deleteConfirm: 'このユーザーを削除し、すべてのセッションを取り消しますか?',
|
||||
created: 'ユーザーを作成しました',
|
||||
deleted: 'ユーザーを削除しました',
|
||||
passwordUpdated: 'パスワードを更新しました',
|
||||
loadFailed: 'ユーザーを読み込めませんでした',
|
||||
saveFailed: 'ユーザーを保存できませんでした',
|
||||
deleteFailed: 'ユーザーを削除できませんでした',
|
||||
editPermissions: '権限',
|
||||
mcpLossTitle: 'MCP アクセスを取り消す',
|
||||
mcpLossDesc:
|
||||
'{{name}} は MCP へのアクセス権を失います。MCP API キーはデバイス全体で共有されるため、コピーされたキーは再生成するまで有効です。再生成すると、新しいキーを設定するまですべての MCP クライアントが切断されます。',
|
||||
mcpLossRotate: 'MCP API キーを再生成する',
|
||||
mcpLossKeep:
|
||||
'現在のキーは引き続き有効で、コピーを持つ人はキーボードとマウスを操作できます。後で MCP 設定から再生成できます。',
|
||||
mcpKeyRotated: 'MCP API キーを再生成しました。MCP クライアントには新しいキーが必要です。'
|
||||
},
|
||||
permissions: {
|
||||
title: '権限',
|
||||
editTitle: '権限を編集',
|
||||
save: '保存',
|
||||
saved: '保存しました',
|
||||
close: '閉じる',
|
||||
groupConsole: 'コンソール',
|
||||
groupTarget: 'ターゲットマシン',
|
||||
groupLibrary: 'メディアライブラリ',
|
||||
groupHost: 'このデバイス',
|
||||
groupServices: 'サービス',
|
||||
consoleView: '画面を表示',
|
||||
consoleControl: 'キーボードとマウス',
|
||||
power: '電源とウェイク',
|
||||
mediumAttach: 'メディアを接続',
|
||||
libraryRead: 'ライブラリを閲覧',
|
||||
libraryWrite: 'ライブラリを管理',
|
||||
terminal: 'ターミナルとスクリプト',
|
||||
device: 'デバイス設定',
|
||||
network: 'ネットワーク設定',
|
||||
update: 'システムアップデート',
|
||||
mcp: 'MCP サービス',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI アシスタント',
|
||||
effectConsoleView:
|
||||
'ターゲットの画面を表示し、すべての視聴者が共有するビデオモード、解像度、フレームレート、画質を設定します。',
|
||||
effectConsoleControl: 'キーボードとマウスの入力をターゲットマシンに送信します。',
|
||||
effectPower:
|
||||
'ターゲットマシンの電源投入、電源切断、リセットを行い、Wake-on-LAN のデバイス一覧を管理します。',
|
||||
effectMediumAttach:
|
||||
'メディアをターゲットマシンにマウント・取り出し、共有パーティション経由でファイルを交換します。',
|
||||
effectLibraryRead: 'イメージとその中のファイルを一覧表示・ダウンロードします。',
|
||||
effectLibraryWrite:
|
||||
'イメージのアップロード・作成・削除と、イメージ内のファイルの変更を行います。',
|
||||
effectTerminal: 'Web セッションを介さずに、このデバイスで root シェルを提供します。',
|
||||
effectDevice:
|
||||
'SSH の有効化(これも root 相当)、デバイスの再起動、生のデータパーティションをターゲットマシンへ公開、OLED・HDMI・mDNS・メモリ・スワップの設定変更ができます。',
|
||||
effectNetwork:
|
||||
'Wi-Fi、DNS、TLS を変更します。このデバイスがネットワークから切断される可能性があります。',
|
||||
effectUpdate: 'ファームウェアとアプリケーションパッケージをインストールします。',
|
||||
effectMcp:
|
||||
'デバイス全体で有効な API キーを発行し、Web セッション外からキーボード・マウス・スクリーンショットを操作できます。この権限を取り消しても、コピーされたキーは再生成しない限り有効です(権限の削除時に再生成するか確認します)。',
|
||||
effectTailscale: 'このデバイスを VPN メッシュに参加させます。',
|
||||
effectAi:
|
||||
'他の権限に制限されないローカルインターフェース経由でデバイスを操作するため、実質的にデバイスの完全な制御権になります。',
|
||||
fullControlWarning: 'これらの権限はデバイスの完全な制御権に相当します:',
|
||||
emptyWarning: 'このアカウントは機能を一切持たず、ログインのみ可能になります。',
|
||||
impliedBy: '{{name}} に含まれます',
|
||||
grantCount_one: '{{count}} 件の権限',
|
||||
grantCount: '{{count}} 件の権限',
|
||||
adminAll: '管理者はすべての権限を持ちます',
|
||||
catalogFailed: '権限一覧を読み込めませんでした。ダイアログを閉じて再試行してください。'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const ko = {
|
||||
login: '로그인',
|
||||
changePassword: '비밀번호 변경',
|
||||
terminal: '터미널',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: '이미지 및 파일 공유'
|
||||
},
|
||||
auth: {
|
||||
login: '로그인',
|
||||
@@ -38,7 +39,10 @@ const ko = {
|
||||
change2: '웹 로그인 비밀번호',
|
||||
change3: '시스템 루트 비밀번호 (SSH 로그인 비밀번호)',
|
||||
change4: '비밀번호를 재설정하려면 NanoKVM의 BOOT 버튼을 길게 누르세요.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: '현재 비밀번호',
|
||||
passwordLength: '비밀번호는 8~72자여야 합니다',
|
||||
invalidCurrentPassword: '현재 비밀번호가 올바르지 않습니다'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -57,6 +61,17 @@ const ko = {
|
||||
verifyBtn: '확인'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: '보기 전용',
|
||||
readOnlyTip:
|
||||
'이 계정은 대상 컴퓨터로 키보드나 마우스 입력을 보낼 수 없으므로 콘솔은 화면만 표시합니다.'
|
||||
},
|
||||
noConsole: {
|
||||
title: '이 계정에는 콘솔 권한이 없습니다',
|
||||
description:
|
||||
'다른 기능이 부여되어 있습니다. 사용할 수 있는 페이지를 열거나 관리자에게 콘솔 권한을 요청하세요.',
|
||||
nothing: '이 계정에는 아직 권한이 없습니다. 관리자에게 부여를 요청하세요.'
|
||||
},
|
||||
screen: {
|
||||
scale: '규모',
|
||||
title: '화면',
|
||||
@@ -209,33 +224,44 @@ const ko = {
|
||||
disable: 'HID 전용 모드 비활성화'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '이미지',
|
||||
loading: '불러오는 중...',
|
||||
empty: '아무것도 없습니다.',
|
||||
storage: {
|
||||
title: '이미지 및 파일 공유',
|
||||
empty:
|
||||
'아직 이미지가 없습니다. ISO 또는 IMG를 업로드하거나 대상 컴퓨터에서 디스크로 복사하세요.',
|
||||
notAttached: '마운트된 항목 없음',
|
||||
attached: '마운트됨: {{name}}',
|
||||
eject: '꺼내기',
|
||||
mount: '마운트',
|
||||
mountMode: '마운트 모드',
|
||||
mountFailed: '마운트 실패',
|
||||
mountDesc:
|
||||
'일부 시스템에서는 이미지를 마운트하기 전에 원격 호스트에서 가상 디스크를 제거해야 합니다.',
|
||||
diskOnWarn:
|
||||
'이미지를 마운트하면 가상 디스크가 꺼지고 컴퓨터에서 /data에 접근할 수 없게 됩니다. 계속하시겠습니까?',
|
||||
diskOffWarn:
|
||||
'가상 디스크가 꺼져 있어 이미지를 마운트하려면 먼저 켜야 합니다. 컴퓨터의 USB 장치가 다시 연결됩니다. 계속하시겠습니까?',
|
||||
diskShared:
|
||||
'가상 디스크가 켜져 있어 이 기기에서 /data는 읽기 전용입니다. 이미지를 삭제하거나 업로드하려면 가상 디스크를 끄세요.',
|
||||
unmountFailed: '언마운트 실패',
|
||||
unmountDesc:
|
||||
'일부 시스템에서는 이미지를 언마운트하기 전에 원격 호스트에서 수동으로 제거하여야 합니다.',
|
||||
refresh: '이미지 목록 새로고침',
|
||||
attention: '주의',
|
||||
deleteConfirm: '이 이미지를 제거하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오',
|
||||
mountModeDisk: '대용량 저장소',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: '파일 관리자 열기',
|
||||
refresh: '새로 고침',
|
||||
delete: '삭제',
|
||||
deleteTitle: '미디어 삭제',
|
||||
deleteConfirm: '{{name}}을(를) 삭제할까요? 안에 있는 파일이 사라집니다.',
|
||||
downloadImage: '이미지 다운로드',
|
||||
typePartition: '공유 파일',
|
||||
volumeLabel: '이동식 디스크',
|
||||
groups: {
|
||||
share: '파일 공유',
|
||||
images: '이미지'
|
||||
},
|
||||
failed: '작업 실패',
|
||||
hidOnly: 'HID 전용 모드: 가상 디스크를 사용할 수 없습니다',
|
||||
disabled: '가상 디스크가 꺼져 있습니다 (설정 - 장치)',
|
||||
force: {
|
||||
title: '미디어를 강제로 되찾을까요?',
|
||||
desc: '대상 컴퓨터가 아직 이 미디어를 사용하고 있습니다. 되찾으면 USB 장치가 다시 열거되고 아직 디스크에 기록하지 않은 내용이 사라질 수 있습니다.',
|
||||
menuHint:
|
||||
'대상 컴퓨터가 이 미디어를 사용하고 있습니다. 파일 관리자를 열어 강제로 되찾으세요.'
|
||||
},
|
||||
tips: {
|
||||
title: '업로드 방법',
|
||||
usb1: 'USB를 통해 NanoKVM을 컴퓨터에 연결하세요.',
|
||||
usb2: '가상 디스크가 마운트되었는지 확인하세요. (설정 - 가상 디스크).',
|
||||
usb2: '이 페이지에서 공유 파일을 대용량 저장 장치 모드로 마운트합니다.',
|
||||
usb3: '컴퓨터에서 가상 디스크를 열고 이미지 파일을 가상 디스크의 루트 디렉토리로 복사하세요.',
|
||||
usb4: '복사가 끝나면 디스크를 꺼냅니다. 그러면 이미지가 목록에 나타납니다.',
|
||||
scp1: 'NanoKVM과 컴퓨터가 동일한 로컬 네트워크에 있는지 확인하세요.',
|
||||
scp2: '컴퓨터에서 터미널을 열고 SCP 명령을 사용하여 이미지 파일을 NanoKVM의 /data 디렉터리에 업로드하세요.',
|
||||
scp3: '예시: scp [이미지 파일 경로] root@[NanoKVM IP 주소]:/data',
|
||||
@@ -245,7 +271,67 @@ const ko = {
|
||||
tf3: 'TF 카드를 카드 리더기에 삽입하고 컴퓨터에 연결하세요.',
|
||||
tf4: '이미지 파일을 TF 카드의 /data 디렉터리에 복사하세요.',
|
||||
tf5: 'TF 카드를 NanoKVM에 삽입하세요.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '{{name}} 처리 중'
|
||||
},
|
||||
enableDisk: {
|
||||
title: '가상 디스크를 켤까요?',
|
||||
desc: '미디어를 마운트하려면 현재 꺼져 있는 가상 디스크가 필요합니다. 지금 켤까요? 대상 컴퓨터의 키보드와 마우스가 잠시 연결 해제됩니다.',
|
||||
menuHint: '가상 디스크가 꺼져 있습니다. 설정 - 장치에서 켜세요.',
|
||||
askDevice: '가상 디스크가 꺼져 있습니다. 장치 권한이 있는 계정에서 먼저 켜야 합니다.'
|
||||
},
|
||||
add: {
|
||||
title: '미디어 추가',
|
||||
upload: 'ISO 또는 IMG 업로드',
|
||||
fromUrl: 'URL에서 다운로드',
|
||||
url: '이미지 URL',
|
||||
urlRequired: 'URL을 입력하세요',
|
||||
onlyImages: '.iso 및 .img 파일만 마운트할 수 있습니다',
|
||||
dropHint: 'ISO 또는 IMG를 여기로 끌어오거나 클릭하여 파일을 선택하세요',
|
||||
cdromOnlyIso: 'CD-ROM에는 ISO 9660 이미지만 넣을 수 있습니다',
|
||||
uploadFailed: '업로드 실패',
|
||||
downloadFailed: '다운로드 실패',
|
||||
uploading: '{{name}} 업로드 중',
|
||||
downloading: '{{name}} 다운로드 중',
|
||||
done: '{{name}} 준비 완료',
|
||||
checksumFailed: '이미지가 SHA-256 체크섬과 일치하지 않습니다.',
|
||||
blocked: '대상 컴퓨터가 디스크를 사용 중입니다',
|
||||
copyHint:
|
||||
'SCP로 데이터 파티션에 이미지를 복사하거나 대상 컴퓨터에서 직접 복사할 수도 있습니다.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: '미디어를 선택하면 안에 있는 파일을 볼 수 있습니다.',
|
||||
reasonAttached: '이 미디어는 대상 컴퓨터에 연결되어 있습니다.',
|
||||
reasonAttachedHint: '먼저 꺼내세요. 대상 컴퓨터가 디스크를 놓아야 파일을 읽을 수 있습니다.',
|
||||
reasonNotBrowsable: '이 미디어에는 파일 관리자가 열 수 없는 파일 시스템이 있습니다.',
|
||||
reasonNoLoop: '이 장치는 디스크 이미지를 마운트할 수 없습니다(loop 미지원).',
|
||||
reasonISOImage:
|
||||
'ISO 9660 이미지의 내용은 여기에서 볼 수 없습니다. 대상 컴퓨터에는 그대로 마운트할 수 있습니다.',
|
||||
empty: '이 폴더는 비어 있습니다',
|
||||
noMatch: '검색과 일치하는 파일이 없습니다.',
|
||||
free: '{{size}} 남음',
|
||||
search: '이 폴더에서 검색',
|
||||
sort: {
|
||||
name: '이름',
|
||||
size: '크기',
|
||||
type: '종류',
|
||||
asc: '오름차순',
|
||||
desc: '내림차순'
|
||||
},
|
||||
upload: '미디어에 파일 업로드',
|
||||
deleteFolderTitle: '폴더 삭제',
|
||||
deleteFolderConfirm:
|
||||
'폴더 {{name}}와(과) 그 안의 모든 내용을 삭제할까요? 되돌릴 수 없습니다.',
|
||||
uploading: '업로드 중…',
|
||||
uploadFailed: '업로드 실패',
|
||||
download: '다운로드',
|
||||
delete: '삭제',
|
||||
deleteTitle: '파일 삭제',
|
||||
deleteConfirm: '미디어에서 {{name}}을(를) 삭제할까요?'
|
||||
},
|
||||
yes: '예',
|
||||
no: '아니요'
|
||||
},
|
||||
script: {
|
||||
title: '스크립트',
|
||||
@@ -287,21 +373,11 @@ const ko = {
|
||||
ok: '확인'
|
||||
},
|
||||
download: {
|
||||
title: '이미지 다운로드',
|
||||
input: '원격 이미지 URL을 입력하세요.',
|
||||
ok: '확인',
|
||||
disabled: '/data 파티션이 읽기 전용(RO) 상태이므로 이미지를 다운로드할 수 없습니다.',
|
||||
diskShared: '먼저 가상 디스크를 끈 다음 이미지 다운로드를 사용하세요.',
|
||||
diskOff: '가상 디스크 끄기',
|
||||
uploadbox: '여기에 파일을 놓거나 클릭하여 선택하세요.',
|
||||
inputfile: '이미지 파일을 입력해주세요',
|
||||
NoISO: 'ISO 없음',
|
||||
disabled:
|
||||
'대상 컴퓨터가 사용하는 동안 데이터 파티션은 읽기 전용입니다. 새 이미지는 쓸 수 없지만 기존 파일은 내려받을 수 있습니다.',
|
||||
sha256: 'SHA-256 (선택 사항)',
|
||||
sha256Placeholder: '64자 SHA-256 체크섬을 입력하세요',
|
||||
invalidSHA256: 'SHA-256은 64자의 16진수 문자열이어야 합니다',
|
||||
failed: '다운로드 실패',
|
||||
success: '다운로드 성공',
|
||||
checksumFailed: '다운로드 실패: SHA-256 검증 실패',
|
||||
cancel: '취소',
|
||||
cancelFailed: '다운로드 취소 실패'
|
||||
},
|
||||
@@ -450,16 +526,18 @@ const ko = {
|
||||
hidOnlyDesc: '가상 장치 에뮬레이션을 중지하고 기본 HID 제어만 유지합니다.',
|
||||
disk: '가상 디스크',
|
||||
diskDesc: '원격 호스트에서 가상 USB를 마운트합니다.',
|
||||
diskLocked: '이미지가 마운트된 동안 꺼짐 (켜면 마운트가 해제됩니다)',
|
||||
diskCancelMount:
|
||||
'가상 디스크를 켜면 마운트된 이미지가 해제되고 진행 중인 이미지 다운로드/업로드도 중단됩니다. 계속하시겠습니까?',
|
||||
diskBreakDownload: '가상 디스크를 켜면 이미지 다운로드가 중단됩니다. 계속하시겠습니까?',
|
||||
network: '가상 네트워크',
|
||||
networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.',
|
||||
reboot: '재부팅',
|
||||
rebootDesc: 'NanoKVM을 재부팅하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
cancelBtn: '아니오',
|
||||
diskLocked: '이미지가 마운트된 동안 꺼짐 (켜면 마운트가 해제됩니다)',
|
||||
diskCancelMount:
|
||||
'가상 디스크를 켜면 마운트된 이미지가 해제되고 진행 중인 이미지 다운로드/업로드도 중단됩니다. 계속하시겠습니까?',
|
||||
diskBreakDownload: '가상 디스크를 켜면 이미지 다운로드가 중단됩니다. 계속하시겠습니까?',
|
||||
attention: '주의',
|
||||
updateFailed: '가상 장치를 업데이트하지 못했습니다'
|
||||
},
|
||||
network: {
|
||||
title: '네트워크',
|
||||
@@ -596,7 +674,87 @@ const ko = {
|
||||
logoutBtn: '로그아웃',
|
||||
logoutDesc: '정말로 로그아웃 하시겠습니까?',
|
||||
okBtn: '네',
|
||||
cancelBtn: '아니오'
|
||||
cancelBtn: '아니오',
|
||||
role: '역할',
|
||||
roles: {
|
||||
admin: '관리자',
|
||||
user: '사용자'
|
||||
},
|
||||
users: {
|
||||
title: '사용자',
|
||||
create: '사용자 만들기',
|
||||
enabled: '사용',
|
||||
disabled: '사용 안 함',
|
||||
deviceOwner: '장치 소유자',
|
||||
resetPassword: '비밀번호 재설정',
|
||||
delete: '삭제',
|
||||
deleteConfirm: '이 사용자를 삭제하고 모든 세션을 취소할까요?',
|
||||
created: '사용자 생성됨',
|
||||
deleted: '사용자 삭제됨',
|
||||
passwordUpdated: '비밀번호 업데이트됨',
|
||||
loadFailed: '사용자를 불러오지 못했습니다',
|
||||
saveFailed: '사용자를 저장하지 못했습니다',
|
||||
deleteFailed: '사용자를 삭제하지 못했습니다',
|
||||
editPermissions: '권한',
|
||||
mcpLossTitle: 'MCP 접근 취소',
|
||||
mcpLossDesc:
|
||||
'{{name}} 계정이 MCP 접근 권한을 잃습니다. MCP API 키는 장치 전체가 공유하므로 복사된 키는 재생성할 때까지 계속 유효합니다. 재생성하면 새 키를 받을 때까지 모든 MCP 클라이언트의 연결이 끊어집니다.',
|
||||
mcpLossRotate: 'MCP API 키 재생성',
|
||||
mcpLossKeep:
|
||||
'현재 키는 계속 유효하며, 사본을 가진 사람은 여전히 키보드와 마우스를 제어할 수 있습니다. 나중에 MCP 설정에서 재생성할 수 있습니다.',
|
||||
mcpKeyRotated: 'MCP API 키를 재생성했습니다. MCP 클라이언트에 새 키가 필요합니다.'
|
||||
},
|
||||
permissions: {
|
||||
title: '권한',
|
||||
editTitle: '권한 편집',
|
||||
save: '저장',
|
||||
saved: '저장됨',
|
||||
close: '닫기',
|
||||
groupConsole: '콘솔',
|
||||
groupTarget: '대상 머신',
|
||||
groupLibrary: '미디어 라이브러리',
|
||||
groupHost: '이 장치',
|
||||
groupServices: '서비스',
|
||||
consoleView: '화면 보기',
|
||||
consoleControl: '키보드 및 마우스',
|
||||
power: '전원 및 깨우기',
|
||||
mediumAttach: '미디어 연결',
|
||||
libraryRead: '라이브러리 탐색',
|
||||
libraryWrite: '라이브러리 관리',
|
||||
terminal: '터미널 및 스크립트',
|
||||
device: '장치 설정',
|
||||
network: '네트워크 설정',
|
||||
update: '시스템 업데이트',
|
||||
mcp: 'MCP 서비스',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI 어시스턴트',
|
||||
effectConsoleView:
|
||||
'대상 화면을 표시하고 모든 시청자가 공유하는 비디오 모드, 해상도, 프레임 속도, 화질을 설정합니다.',
|
||||
effectConsoleControl: '키보드와 마우스 입력을 대상 머신으로 보냅니다.',
|
||||
effectPower:
|
||||
'대상 머신의 전원을 켜고 끄고 재설정하며, Wake-on-LAN 장치 목록을 관리합니다.',
|
||||
effectMediumAttach:
|
||||
'미디어를 대상 머신에 마운트하거나 분리하고, 공유 파티션을 통해 파일을 주고받습니다.',
|
||||
effectLibraryRead: '이미지와 그 안의 파일을 나열하고 내려받습니다.',
|
||||
effectLibraryWrite: '이미지를 업로드, 생성, 삭제하고 이미지 안의 파일을 변경합니다.',
|
||||
effectTerminal: '웹 세션을 거치지 않고 이 장치에서 root 셸을 제공합니다.',
|
||||
effectDevice:
|
||||
'SSH 활성화(역시 root), 장치 재부팅, 원시 데이터 파티션을 대상 머신에 제공, OLED·HDMI·mDNS·메모리·스왑 설정 변경이 가능합니다.',
|
||||
effectNetwork: 'Wi-Fi, DNS, TLS를 변경하며, 이 장치가 네트워크에서 끊길 수 있습니다.',
|
||||
effectUpdate: '펌웨어와 응용 프로그램 패키지를 설치합니다.',
|
||||
effectMcp:
|
||||
'장치 전체에 유효한 API 키를 발급하여 웹 세션 밖에서 키보드, 마우스, 스크린샷을 제어합니다. 이 권한을 취소해도 복사된 키는 재생성하지 않는 한 유효합니다(권한을 제거할 때 재생성 여부를 묻습니다).',
|
||||
effectTailscale: '이 장치를 VPN 메시에 연결합니다.',
|
||||
effectAi:
|
||||
'다른 권한의 제한을 받지 않는 로컬 인터페이스로 장치를 제어하므로 사실상 완전한 장치 제어 권한입니다.',
|
||||
fullControlWarning: '이 권한들은 장치에 대한 완전한 제어 권한에 해당합니다:',
|
||||
emptyWarning: '이 계정은 어떤 기능도 없이 로그인만 할 수 있습니다.',
|
||||
impliedBy: '{{name}}에 포함됨',
|
||||
grantCount_one: '권한 {{count}}개',
|
||||
grantCount: '권한 {{count}}개',
|
||||
adminAll: '관리자는 모든 권한을 가집니다',
|
||||
catalogFailed: '권한 목록을 불러오지 못했습니다. 창을 닫고 다시 시도하세요.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const nb = {
|
||||
login: 'Logg inn',
|
||||
changePassword: 'Endre passord',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Diskbilder og fildeling'
|
||||
},
|
||||
auth: {
|
||||
login: 'Logg inn',
|
||||
@@ -40,7 +41,10 @@ const nb = {
|
||||
change2: 'Passord for webinnlogging',
|
||||
change3: 'Systemets root-passord (SSH-innloggingspassord)',
|
||||
change4: 'For å tilbakestille passordene holder du BOOT-knappen på NanoKVM inne.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Nåværende passord',
|
||||
passwordLength: 'Passordet må være mellom 8 og 72 tegn',
|
||||
invalidCurrentPassword: 'Det nåværende passordet er feil'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const nb = {
|
||||
verifyBtn: 'Bekreft'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Kun visning',
|
||||
readOnlyTip:
|
||||
'Denne kontoen kan ikke sende tastatur- eller musinndata til målmaskinen, så konsollen viser bare skjermen.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Denne kontoen har ikke konsoltilgang',
|
||||
description:
|
||||
'Den har fått andre tillatelser. Åpne en av sidene den kan bruke, eller be en administrator om konsoltilgang.',
|
||||
nothing: 'Denne kontoen har ingen tillatelser ennå. Be en administrator om å gi noen.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Skjerm',
|
||||
@@ -214,33 +229,44 @@ const nb = {
|
||||
disable: 'Deaktiver HID-bare-modus'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Bilder',
|
||||
loading: 'Laster...',
|
||||
empty: 'Ingen funnet',
|
||||
storage: {
|
||||
title: 'Diskbilder og fildeling',
|
||||
empty:
|
||||
'Ingen bilder ennå. Last opp en ISO eller IMG, eller kopier en over til disken fra målmaskinen.',
|
||||
notAttached: 'Ingenting montert',
|
||||
attached: 'Montert: {{name}}',
|
||||
eject: 'Løs ut',
|
||||
mount: 'Monter',
|
||||
mountMode: 'Monteringsmodus',
|
||||
mountFailed: 'Montering feilet',
|
||||
mountDesc:
|
||||
'På noen systemer er det nødvendig å koble fra den virtuelle disken på den eksterne verten før man kan montere arkivfilen.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Avmontering mislyktes',
|
||||
unmountDesc:
|
||||
'På noen systemer må du manuelt løse ut fra den eksterne verten før du demonterer bildet.',
|
||||
refresh: 'Oppdater bildelisten',
|
||||
attention: 'Merknad',
|
||||
deleteConfirm: 'Er du sikker på at du vil slette dette bildet?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nei',
|
||||
mountModeDisk: 'Masselagring',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Åpne filbehandler',
|
||||
refresh: 'Oppdater',
|
||||
delete: 'Slett',
|
||||
deleteTitle: 'Slett medium',
|
||||
deleteConfirm: 'Vil du slette {{name}}? Filene i den går tapt.',
|
||||
downloadImage: 'Last ned bildet',
|
||||
typePartition: 'Delte filer',
|
||||
volumeLabel: 'Flyttbar disk',
|
||||
groups: {
|
||||
share: 'Fildeling',
|
||||
images: 'Diskbilder'
|
||||
},
|
||||
failed: 'Handlingen mislyktes',
|
||||
hidOnly: 'Kun HID-modus: den virtuelle disken er utilgjengelig',
|
||||
disabled: 'Den virtuelle disken er slått av (Innstillinger - Enhet)',
|
||||
force: {
|
||||
title: 'Ta mediet tilbake med makt?',
|
||||
desc: 'Målmaskinen bruker fortsatt dette mediet. Å ta det tilbake registrerer USB-enheten på nytt og kan miste alt den ennå ikke har skrevet til disken.',
|
||||
menuHint:
|
||||
'Målmaskinen bruker dette mediet. Åpne filbehandleren for å ta det tilbake med makt.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Hvordan laste opp',
|
||||
usb1: 'Koble til NanoKVM-enheten til din datamaskin med USB.',
|
||||
usb2: 'Sikre at den virtuelle disken er montert (Innstillinger - Virtuell disk).',
|
||||
usb2: 'Monter de delte filene i masselagringsmodus på denne siden.',
|
||||
usb3: 'Åpne den virtuelle disken på datamaskinen din og kopier arkivfilen til rot-mappen på den virtuelle disken.',
|
||||
usb4: 'Løs ut disken når kopieringen er ferdig: bildet vises da i listen.',
|
||||
scp1: 'Sikre at NanoKVM-enheten og datamaskinen din er tilkoblet det samme lokale nettverket.',
|
||||
scp2: 'Åpne en terminal på datamaskinen din og bruk SCP-kommandoen til å laste opp arkivfilen til mappen /data på NanoKVM-enheten.',
|
||||
scp3: 'Eksempel: scp sti-til-din-arkivfil root@din-nanokvm-ip:/data',
|
||||
@@ -250,7 +276,68 @@ const nb = {
|
||||
tf3: 'Sett inn TF-kortet i en kortleser og koble den til datamaskinen din.',
|
||||
tf4: 'Kopiér arkivfilen til mappen /data på TF-kortet.',
|
||||
tf5: 'Sett inn TF-kortet i NanoKVM-enheten.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Arbeider med {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Slå på den virtuelle disken?',
|
||||
desc: 'For å montere et medium trengs den virtuelle disken, som er slått av nå. Slå den på? Tastaturet og musen til målmaskinen kobles fra et øyeblikk.',
|
||||
menuHint: 'Den virtuelle disken er slått av. Slå den på under Innstillinger - Enhet.',
|
||||
askDevice:
|
||||
'Den virtuelle disken er slått av. En konto med enhetstillatelse må aktivere den først.'
|
||||
},
|
||||
add: {
|
||||
title: 'Legg til medium',
|
||||
upload: 'Last opp en ISO eller IMG',
|
||||
fromUrl: 'Last ned fra en URL',
|
||||
url: 'URL til bildet',
|
||||
urlRequired: 'Skriv inn en URL',
|
||||
onlyImages: 'Bare .iso- og .img-filer kan monteres',
|
||||
dropHint: 'Dra en ISO eller IMG hit, eller klikk for å velge en fil',
|
||||
cdromOnlyIso: 'En CD-ROM kan bare inneholde et ISO 9660-bilde',
|
||||
uploadFailed: 'Opplasting mislyktes',
|
||||
downloadFailed: 'Nedlasting mislyktes',
|
||||
uploading: 'Laster opp {{name}}',
|
||||
downloading: 'Laster ned {{name}}',
|
||||
done: '{{name}} er klar',
|
||||
checksumFailed: 'Bildet samsvarer ikke med SHA-256-kontrollsummen.',
|
||||
blocked: 'Målmaskinen bruker disken',
|
||||
copyHint: 'Du kan også kopiere et bilde til datapartisjonen via SCP, eller fra målmaskinen.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Velg et medium for å se filene på det.',
|
||||
reasonAttached: 'Dette mediet er koblet til målmaskinen.',
|
||||
reasonAttachedHint:
|
||||
'Løs det ut først: målmaskinen må frigjøre disken før filene kan leses.',
|
||||
reasonNotBrowsable:
|
||||
'Dette mediet inneholder et filsystem som filbehandleren ikke kan åpne.',
|
||||
reasonNoLoop: 'Denne enheten kan ikke montere diskbilder (ingen loop-støtte).',
|
||||
reasonISOImage:
|
||||
'Et ISO 9660-bilde kan ikke utforskes her. Det kan fortsatt monteres på målmaskinen.',
|
||||
empty: 'Denne mappen er tom',
|
||||
noMatch: 'Ingen filer samsvarer med søket.',
|
||||
free: '{{size}} ledige',
|
||||
search: 'Søk i denne mappen',
|
||||
sort: {
|
||||
name: 'Navn',
|
||||
size: 'Størrelse',
|
||||
type: 'Type',
|
||||
asc: 'Stigende',
|
||||
desc: 'Synkende'
|
||||
},
|
||||
upload: 'Last opp en fil til mediet',
|
||||
deleteFolderTitle: 'Slett mappe',
|
||||
deleteFolderConfirm: 'Slette mappen {{name}} og alt innholdet? Dette kan ikke angres.',
|
||||
uploading: 'Laster opp…',
|
||||
uploadFailed: 'Opplasting mislyktes',
|
||||
download: 'Last ned',
|
||||
delete: 'Slett',
|
||||
deleteTitle: 'Slett fil',
|
||||
deleteConfirm: 'Vil du slette {{name}} fra mediet?'
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nei'
|
||||
},
|
||||
script: {
|
||||
title: 'Skript',
|
||||
@@ -292,21 +379,11 @@ const nb = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Bildedaster',
|
||||
input: 'Vennligst skriv inn et eksternt bilde URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partisjonen er RO, så vi kan ikke laste ned bildet',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Slipp filen her eller klikk for å velge',
|
||||
inputfile: 'Vennligst skriv inn bildefilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
disabled:
|
||||
'Datapartisjonen er skrivebeskyttet mens målmaskinen bruker den: nye bilder kan ikke skrives, men eksisterende filer kan fortsatt lastes ned.',
|
||||
sha256: 'SHA-256 (valgfritt)',
|
||||
sha256Placeholder: 'Skriv inn en SHA-256-kontrollsum på 64 tegn',
|
||||
invalidSHA256: 'SHA-256 må være en heksadesimal streng på 64 tegn',
|
||||
failed: 'Nedlasting mislyktes',
|
||||
success: 'Nedlasting fullført',
|
||||
checksumFailed: 'Nedlasting mislyktes: SHA-256-verifisering mislyktes',
|
||||
cancel: 'Avbryt',
|
||||
cancelFailed: 'Kunne ikke avbryte nedlastingen'
|
||||
},
|
||||
@@ -454,16 +531,18 @@ const nb = {
|
||||
hidOnlyDesc: 'Slutt å emulere virtuelle enheter, behold bare grunnleggende HID-kontroll',
|
||||
disk: 'Virtuell disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Virtuelt nettverk',
|
||||
networkDesc: 'Monter virtuelt nettverkskort på den eksterne verten',
|
||||
reboot: 'Start på nytt',
|
||||
rebootDesc: 'Er du sikker på at du vil starte NanoKVM på nytt?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nei'
|
||||
cancelBtn: 'Nei',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Merknad',
|
||||
updateFailed: 'Kunne ikke oppdatere den virtuelle enheten'
|
||||
},
|
||||
network: {
|
||||
title: 'Nettverk',
|
||||
@@ -602,7 +681,90 @@ const nb = {
|
||||
logoutBtn: 'Logg ut',
|
||||
logoutDesc: 'Er du sikker på at du vil logge ut?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nei'
|
||||
cancelBtn: 'Nei',
|
||||
role: 'Rolle',
|
||||
roles: {
|
||||
admin: 'Administrator',
|
||||
user: 'Bruker'
|
||||
},
|
||||
users: {
|
||||
title: 'Brukere',
|
||||
create: 'Opprett bruker',
|
||||
enabled: 'Aktivert',
|
||||
disabled: 'Deaktivert',
|
||||
deviceOwner: 'Enhetens eier',
|
||||
resetPassword: 'Tilbakestill passord',
|
||||
delete: 'Slett',
|
||||
deleteConfirm: 'Vil du slette denne brukeren og tilbakekalle alle øktene dens?',
|
||||
created: 'Bruker opprettet',
|
||||
deleted: 'Bruker slettet',
|
||||
passwordUpdated: 'Passord oppdatert',
|
||||
loadFailed: 'Kunne ikke laste brukere',
|
||||
saveFailed: 'Kunne ikke lagre brukeren',
|
||||
deleteFailed: 'Kunne ikke slette brukeren',
|
||||
editPermissions: 'Tillatelser',
|
||||
mcpLossTitle: 'Fjern MCP-tilgang',
|
||||
mcpLossDesc:
|
||||
'{{name}} mister MCP-tilgang. MCP-API-nøkkelen deles av hele enheten, så en kopi virker til nøkkelen genereres på nytt. Å generere den på nytt kobler fra alle MCP-klienter til de får den nye nøkkelen.',
|
||||
mcpLossRotate: 'Generer MCP-API-nøkkelen på nytt',
|
||||
mcpLossKeep:
|
||||
'Den nåværende nøkkelen forblir gyldig, og den som har en kopi kan fortsatt styre tastatur og mus. Du kan generere den på nytt senere i MCP-innstillingene.',
|
||||
mcpKeyRotated:
|
||||
'MCP-API-nøkkelen er generert på nytt. MCP-klienter trenger den nye nøkkelen.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Tillatelser',
|
||||
editTitle: 'Rediger tillatelser',
|
||||
save: 'Lagre',
|
||||
saved: 'Lagret',
|
||||
close: 'Lukk',
|
||||
groupConsole: 'Konsoll',
|
||||
groupTarget: 'Målmaskin',
|
||||
groupLibrary: 'Mediebibliotek',
|
||||
groupHost: 'Denne enheten',
|
||||
groupServices: 'Tjenester',
|
||||
consoleView: 'Se skjermen',
|
||||
consoleControl: 'Tastatur og mus',
|
||||
power: 'Strøm og vekking',
|
||||
mediumAttach: 'Koble til et medium',
|
||||
libraryRead: 'Bla gjennom biblioteket',
|
||||
libraryWrite: 'Administrer biblioteket',
|
||||
terminal: 'Terminal og skript',
|
||||
device: 'Enhetsinnstillinger',
|
||||
network: 'Nettverksinnstillinger',
|
||||
update: 'Systemoppdatering',
|
||||
mcp: 'MCP-tjeneste',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI-assistent',
|
||||
effectConsoleView:
|
||||
'Viser målmaskinens skjerm og setter videomodus, oppløsning, bildefrekvens og kvalitet som alle seere deler.',
|
||||
effectConsoleControl: 'Sender tastatur- og musinndata til målmaskinen.',
|
||||
effectPower:
|
||||
'Slår på, slår av og restarter målmaskinen, og administrerer listen over Wake-on-LAN-enheter.',
|
||||
effectMediumAttach:
|
||||
'Monterer og løser ut medier på målmaskinen, og utveksler filer med den via den delte partisjonen.',
|
||||
effectLibraryRead: 'Lister ut og laster ned avbildningene og filene i dem.',
|
||||
effectLibraryWrite:
|
||||
'Laster opp, oppretter og sletter avbildninger, og endrer filene i dem.',
|
||||
effectTerminal: 'Gir et root-skall på denne enheten, uten nettsesjonen imellom.',
|
||||
effectDevice:
|
||||
'Gjør det mulig å slå på SSH (også root), restarte enheten, gi den rå datapartisjonen til målmaskinen og endre OLED, HDMI, mDNS, minne og swap.',
|
||||
effectNetwork:
|
||||
'Endrer Wi-Fi, DNS og TLS, noe som kan koble denne enheten fra nettverket.',
|
||||
effectUpdate: 'Installerer fastvare og programpakker.',
|
||||
effectMcp:
|
||||
'Utsteder en API-nøkkel for hele enheten som styrer tastatur, mus og skjermbilder utenfor nettsesjonen; en kopi virker fortsatt etter at tillatelsen er trukket tilbake med mindre nøkkelen genereres på nytt, noe som tilbys når tillatelsen fjernes.',
|
||||
effectTailscale: 'Kobler denne enheten til et VPN-nett.',
|
||||
effectAi:
|
||||
'Styrer enheten via et lokalt grensesnitt som de andre tillatelsene ikke begrenser, så dette tilsvarer full kontroll over enheten.',
|
||||
fullControlWarning: 'Disse tillatelsene tilsvarer full kontroll over enheten:',
|
||||
emptyWarning: 'Denne kontoen får ingen funksjoner og kan bare logge inn.',
|
||||
impliedBy: 'Inkludert av {{name}}',
|
||||
grantCount_one: '{{count}} tillatelse',
|
||||
grantCount: '{{count}} tillatelser',
|
||||
adminAll: 'Administratorer har alle tillatelser',
|
||||
catalogFailed: 'Tillatelseslisten kunne ikke lastes. Lukk dialogen og prøv igjen.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const nl = {
|
||||
login: 'Inloggen',
|
||||
changePassword: 'Wachtwoord wijzigen',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Afbeeldingen en bestandsdeling'
|
||||
},
|
||||
auth: {
|
||||
login: 'Inloggen',
|
||||
@@ -41,7 +42,10 @@ const nl = {
|
||||
change3: 'Systeem root-wachtwoord (SSH-inlogwachtwoord)',
|
||||
change4:
|
||||
'Om de wachtwoorden opnieuw in te stellen, houdt u de BOOT-knop op de NanoKVM ingedrukt.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Huidig wachtwoord',
|
||||
passwordLength: 'Het wachtwoord moet tussen 8 en 72 tekens lang zijn',
|
||||
invalidCurrentPassword: 'Het huidige wachtwoord is onjuist'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -60,6 +64,17 @@ const nl = {
|
||||
verifyBtn: 'Verifieer'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Alleen bekijken',
|
||||
readOnlyTip:
|
||||
'Dit account kan geen toetsenbord- of muisinvoer naar de doelmachine sturen, daarom toont de console alleen het scherm.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Dit account heeft geen toegang tot de console',
|
||||
description:
|
||||
'Er zijn andere mogelijkheden toegekend. Open een van de pagina’s die het mag gebruiken, of vraag een beheerder om toegang tot de console.',
|
||||
nothing: 'Dit account heeft nog geen rechten. Vraag een beheerder om er een toe te kennen.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Schaal',
|
||||
title: 'Scherm',
|
||||
@@ -217,33 +232,44 @@ const nl = {
|
||||
disable: 'Schakel de modus HID-Alleen uit'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Afbeeldingen',
|
||||
loading: 'Laden...',
|
||||
empty: 'Niets gevonden',
|
||||
storage: {
|
||||
title: 'Afbeeldingen en bestandsdeling',
|
||||
empty:
|
||||
'Nog geen afbeeldingen. Upload een ISO of IMG, of kopieer er een naar de schijf vanaf de doelcomputer.',
|
||||
notAttached: 'Niets gekoppeld',
|
||||
attached: 'Gekoppeld: {{name}}',
|
||||
eject: 'Uitwerpen',
|
||||
mount: 'Koppelen',
|
||||
mountMode: 'Montagemodus',
|
||||
mountFailed: 'Koppelen mislukt',
|
||||
mountDesc:
|
||||
'In sommige systemen is het noodzakelijk om de virtuele schijf op de externe host uit te werpen voordat het image wordt gekoppeld.',
|
||||
diskOnWarn:
|
||||
'Het koppelen van de image schakelt de virtuele schijf uit, zodat de computer geen toegang meer heeft tot /data. Doorgaan?',
|
||||
diskOffWarn:
|
||||
'De virtuele schijf staat uit en moet aan staan om de image te koppelen. De USB-apparaten op de computer worden opnieuw herkend. Doorgaan?',
|
||||
diskShared:
|
||||
'De virtuele schijf staat aan, dus /data is hier alleen-lezen. Schakel deze uit om images te verwijderen of te uploaden.',
|
||||
unmountFailed: 'Ontkoppelen mislukt',
|
||||
unmountDesc:
|
||||
'Op sommige systemen moet u de image handmatig uitwerpen van de externe host voordat u de image ontkoppelt.',
|
||||
refresh: 'Vernieuw de afbeeldingenlijst',
|
||||
attention: 'Let op',
|
||||
deleteConfirm: 'Weet u zeker dat u deze afbeelding wilt verwijderen?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nee',
|
||||
mountModeDisk: 'Massopslag',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Bestandsbeheer openen',
|
||||
refresh: 'Vernieuwen',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: 'Medium verwijderen',
|
||||
deleteConfirm: '{{name}} verwijderen? De bestanden erin gaan verloren.',
|
||||
downloadImage: 'De afbeelding downloaden',
|
||||
typePartition: 'Gedeelde bestanden',
|
||||
volumeLabel: 'Verwisselbare schijf',
|
||||
groups: {
|
||||
share: 'Bestandsdeling',
|
||||
images: 'Afbeeldingen'
|
||||
},
|
||||
failed: 'De bewerking is mislukt',
|
||||
hidOnly: 'Alleen HID-modus: de virtuele schijf is niet beschikbaar',
|
||||
disabled: 'De virtuele schijf is uitgeschakeld (Instellingen - Apparaat)',
|
||||
force: {
|
||||
title: 'Het medium met geweld terughalen?',
|
||||
desc: 'De doelcomputer gebruikt dit medium nog. Het terughalen laat het USB-apparaat opnieuw opsommen en kan alles verliezen wat nog niet naar de schijf is geschreven.',
|
||||
menuHint:
|
||||
'De doelcomputer gebruikt dit medium. Open het bestandsbeheer om het met geweld terug te halen.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Hoe te uploaden',
|
||||
usb1: 'Verbind de NanoKVM met uw computer via USB.',
|
||||
usb2: 'Zorg ervoor dat de virtuele schijf is gekoppeld (Instellingen - Virtuele schijf).',
|
||||
usb2: 'Koppel op deze pagina de gedeelde bestanden in massaopslagmodus.',
|
||||
usb3: 'Open de virtuele schijf op uw computer en kopieer het imagebestand naar de hoofdmap van de virtuele schijf.',
|
||||
usb4: 'Werp de schijf uit als het kopiëren klaar is: de image verschijnt dan in de lijst.',
|
||||
scp1: 'Zorg ervoor dat de NanoKVM en uw computer zich in hetzelfde lokale netwerk bevinden.',
|
||||
scp2: 'Open een terminal op uw computer en gebruik het SCP-commando om het imagebestand te uploaden naar de /data directory op de NanoKVM.',
|
||||
scp3: 'Voorbeeld: scp uw-image-pad root@uw-nanokvm-ip:/data',
|
||||
@@ -253,7 +279,71 @@ const nl = {
|
||||
tf3: 'Plaats de TF-kaart in een kaartlezer en verbind deze met uw computer.',
|
||||
tf4: 'Kopieer het imagebestand naar de /data directory op de TF-kaart.',
|
||||
tf5: 'Plaats de TF-kaart terug in de NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Bezig met {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'De virtuele schijf inschakelen?',
|
||||
desc: 'Voor het koppelen van een medium is de virtuele schijf nodig, die nu uitgeschakeld is. Nu inschakelen? Het toetsenbord en de muis van de doelcomputer worden even losgekoppeld.',
|
||||
menuHint:
|
||||
'De virtuele schijf is uitgeschakeld. Schakel die in bij Instellingen - Apparaat.',
|
||||
askDevice:
|
||||
'De virtuele schijf staat uit. Een account met de apparaatrechten moet deze eerst aanzetten.'
|
||||
},
|
||||
add: {
|
||||
title: 'Medium toevoegen',
|
||||
upload: 'Een ISO of IMG uploaden',
|
||||
fromUrl: 'Vanaf een URL downloaden',
|
||||
url: 'URL van de afbeelding',
|
||||
urlRequired: 'Voer een URL in',
|
||||
onlyImages: 'Alleen .iso- en .img-bestanden kunnen worden gekoppeld',
|
||||
dropHint: 'Sleep een ISO of IMG hierheen, of klik om een bestand te kiezen',
|
||||
cdromOnlyIso: 'Een CD-ROM kan alleen een ISO 9660-image bevatten',
|
||||
uploadFailed: 'Uploaden mislukt',
|
||||
downloadFailed: 'Downloaden mislukt',
|
||||
uploading: '{{name}} wordt geüpload',
|
||||
downloading: '{{name}} wordt gedownload',
|
||||
done: '{{name}} is klaar',
|
||||
checksumFailed: 'De image komt niet overeen met de SHA-256-controlesom.',
|
||||
blocked: 'De doelcomputer gebruikt de schijf',
|
||||
copyHint:
|
||||
'Je kunt een image ook via SCP naar de datapartitie kopiëren, of vanaf de doelcomputer.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Kies een medium om de bestanden erin te bekijken.',
|
||||
reasonAttached: 'Dit medium is aan de doelcomputer gekoppeld.',
|
||||
reasonAttachedHint:
|
||||
'Werp het eerst uit: de doelcomputer moet de schijf vrijgeven voordat de bestanden gelezen kunnen worden.',
|
||||
reasonNotBrowsable:
|
||||
'Dit medium bevat een bestandssysteem dat het bestandsbeheer niet kan openen.',
|
||||
reasonNoLoop: 'Dit apparaat kan geen schijfimages koppelen (geen loop-ondersteuning).',
|
||||
reasonISOImage:
|
||||
'Een ISO 9660-image kan hier niet worden doorzocht. Die kan wel gewoon aan de doelcomputer worden gekoppeld.',
|
||||
empty: 'Deze map is leeg',
|
||||
noMatch: 'Geen bestanden gevonden voor deze zoekopdracht.',
|
||||
free: '{{size}} vrij',
|
||||
search: 'In deze map zoeken',
|
||||
sort: {
|
||||
name: 'Naam',
|
||||
size: 'Grootte',
|
||||
type: 'Type',
|
||||
asc: 'Oplopend',
|
||||
desc: 'Aflopend'
|
||||
},
|
||||
upload: 'Een bestand naar het medium uploaden',
|
||||
deleteFolderTitle: 'Map verwijderen',
|
||||
deleteFolderConfirm:
|
||||
'De map {{name}} en alles erin verwijderen? Dit kan niet ongedaan worden gemaakt.',
|
||||
uploading: 'Uploaden…',
|
||||
uploadFailed: 'Uploaden mislukt',
|
||||
download: 'Downloaden',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: 'Bestand verwijderen',
|
||||
deleteConfirm: '{{name}} van het medium verwijderen?'
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nee'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -295,21 +385,11 @@ const nl = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Afbeeldingdownloader',
|
||||
input: 'Voer een externe afbeelding in URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partitie is RO, dus we kunnen de afbeelding niet downloaden',
|
||||
diskShared: 'Schakel eerst de virtuele schijf uit en gebruik daarna de image-download.',
|
||||
diskOff: 'Virtuele schijf uitschakelen',
|
||||
uploadbox: 'Zet het bestand hier neer of klik om te selecteren',
|
||||
inputfile: 'Voer het afbeeldingsbestand in',
|
||||
NoISO: 'Geen ISO',
|
||||
disabled:
|
||||
'De datapartitie is alleen-lezen zolang de doelcomputer hem gebruikt: nieuwe images kunnen niet worden geschreven, bestaande bestanden zijn nog wel te downloaden.',
|
||||
sha256: 'SHA-256 (optioneel)',
|
||||
sha256Placeholder: 'Voer een SHA-256-controlesom van 64 tekens in',
|
||||
invalidSHA256: 'SHA-256 moet een hexadecimale tekenreeks van 64 tekens zijn',
|
||||
failed: 'Download mislukt',
|
||||
success: 'Download geslaagd',
|
||||
checksumFailed: 'Download mislukt: SHA-256-verificatie mislukt',
|
||||
cancel: 'Annuleren',
|
||||
cancelFailed: 'Download annuleren mislukt'
|
||||
},
|
||||
@@ -461,17 +541,19 @@ const nl = {
|
||||
'Stop met het emuleren van virtuele apparaten en behoud alleen de basisbesturing van HID',
|
||||
disk: 'Virtuele schijf',
|
||||
diskDesc: 'Koppel virtuele U-schijf aan de externe host',
|
||||
diskLocked: 'Uit zolang een image is gekoppeld (aanzetten breekt het koppelen af)',
|
||||
diskCancelMount:
|
||||
'Het inschakelen van de virtuele schijf breekt de gekoppelde image af en onderbreekt een lopende image-download of -upload. Doorgaan?',
|
||||
diskBreakDownload:
|
||||
'Het inschakelen van de virtuele schijf onderbreekt de image-download. Doorgaan?',
|
||||
network: 'Virtueel Netwerk',
|
||||
networkDesc: 'Koppel virtueel netwerk kaart aan de externe host',
|
||||
reboot: 'Opnieuw opstarten',
|
||||
rebootDesc: 'Weet u zeker dat u NanoKVM opnieuw wilt opstarten?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nee'
|
||||
cancelBtn: 'Nee',
|
||||
diskLocked: 'Uit zolang een image is gekoppeld (aanzetten breekt het koppelen af)',
|
||||
diskCancelMount:
|
||||
'Het inschakelen van de virtuele schijf breekt de gekoppelde image af en onderbreekt een lopende image-download of -upload. Doorgaan?',
|
||||
diskBreakDownload:
|
||||
'Het inschakelen van de virtuele schijf onderbreekt de image-download. Doorgaan?',
|
||||
attention: 'Let op',
|
||||
updateFailed: 'Bijwerken van het virtuele apparaat mislukt'
|
||||
},
|
||||
network: {
|
||||
title: 'Netwerk',
|
||||
@@ -610,7 +692,90 @@ const nl = {
|
||||
logoutBtn: 'Afmelden',
|
||||
logoutDesc: 'Weet u zeker dat u wilt uitloggen?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nee'
|
||||
cancelBtn: 'Nee',
|
||||
role: 'Rol',
|
||||
roles: {
|
||||
admin: 'Beheerder',
|
||||
user: 'Gebruiker'
|
||||
},
|
||||
users: {
|
||||
title: 'Gebruikers',
|
||||
create: 'Gebruiker aanmaken',
|
||||
enabled: 'Ingeschakeld',
|
||||
disabled: 'Uitgeschakeld',
|
||||
deviceOwner: 'Apparaateigenaar',
|
||||
resetPassword: 'Wachtwoord opnieuw instellen',
|
||||
delete: 'Verwijderen',
|
||||
deleteConfirm: 'Deze gebruiker verwijderen en al zijn sessies intrekken?',
|
||||
created: 'Gebruiker aangemaakt',
|
||||
deleted: 'Gebruiker verwijderd',
|
||||
passwordUpdated: 'Wachtwoord bijgewerkt',
|
||||
loadFailed: 'Gebruikers konden niet worden geladen',
|
||||
saveFailed: 'Gebruiker kon niet worden opgeslagen',
|
||||
deleteFailed: 'Gebruiker kon niet worden verwijderd',
|
||||
editPermissions: 'Rechten',
|
||||
mcpLossTitle: 'MCP-toegang intrekken',
|
||||
mcpLossDesc:
|
||||
'{{name}} verliest MCP-toegang. De MCP-API-sleutel wordt door het hele apparaat gedeeld, dus een kopie blijft werken tot de sleutel opnieuw wordt gegenereerd. Opnieuw genereren verbreekt alle MCP-clients tot ze de nieuwe sleutel krijgen.',
|
||||
mcpLossRotate: 'MCP-API-sleutel opnieuw genereren',
|
||||
mcpLossKeep:
|
||||
'De huidige sleutel blijft geldig en wie een kopie heeft, kan nog steeds toetsenbord en muis bedienen. U kunt hem later opnieuw genereren in de MCP-instellingen.',
|
||||
mcpKeyRotated:
|
||||
'MCP-API-sleutel opnieuw gegenereerd. MCP-clients hebben de nieuwe sleutel nodig.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Rechten',
|
||||
editTitle: 'Rechten bewerken',
|
||||
save: 'Opslaan',
|
||||
saved: 'Opgeslagen',
|
||||
close: 'Sluiten',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Doelmachine',
|
||||
groupLibrary: 'Mediabibliotheek',
|
||||
groupHost: 'Dit apparaat',
|
||||
groupServices: 'Diensten',
|
||||
consoleView: 'Scherm bekijken',
|
||||
consoleControl: 'Toetsenbord en muis',
|
||||
power: 'Voeding en wekken',
|
||||
mediumAttach: 'Medium koppelen',
|
||||
libraryRead: 'Mediabibliotheek doorbladeren',
|
||||
libraryWrite: 'Mediabibliotheek beheren',
|
||||
terminal: 'Terminal en scripts',
|
||||
device: 'Apparaatinstellingen',
|
||||
network: 'Netwerkinstellingen',
|
||||
update: 'Systeemupdate',
|
||||
mcp: 'MCP-dienst',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI-assistent',
|
||||
effectConsoleView:
|
||||
'Toont het scherm van de doelmachine en stelt de videomodus, resolutie, framesnelheid en kwaliteit in die alle kijkers delen.',
|
||||
effectConsoleControl: 'Stuurt toetsenbord- en muisinvoer naar de doelmachine.',
|
||||
effectPower:
|
||||
'Zet de doelmachine aan, uit en herstart hem, en beheert de lijst met Wake-on-LAN-apparaten.',
|
||||
effectMediumAttach:
|
||||
'Koppelt en ontkoppelt media op de doelmachine en wisselt bestanden met hem uit via de gedeelde partitie.',
|
||||
effectLibraryRead: 'Toont en downloadt de images en de bestanden erin.',
|
||||
effectLibraryWrite: 'Uploadt, maakt en verwijdert images, en wijzigt de bestanden erin.',
|
||||
effectTerminal: 'Geeft een root-shell op dit apparaat, zonder de websessie ertussen.',
|
||||
effectDevice:
|
||||
'Maakt SSH mogelijk (ook root), herstart het apparaat, geeft de ruwe datapartitie aan de doelmachine en wijzigt OLED, HDMI, mDNS, geheugen en swap.',
|
||||
effectNetwork:
|
||||
'Wijzigt wifi, DNS en TLS, waardoor dit apparaat van het netwerk kan verdwijnen.',
|
||||
effectUpdate: 'Installeert firmware en applicatiepakketten.',
|
||||
effectMcp:
|
||||
'Geeft een apparaatbrede API-sleutel uit die toetsenbord, muis en schermafbeeldingen buiten de websessie bestuurt; een kopie blijft na intrekking werken tenzij de sleutel opnieuw wordt gegenereerd, wat wordt aangeboden bij het verwijderen van de toestemming.',
|
||||
effectTailscale: 'Voegt dit apparaat toe aan een VPN-mesh.',
|
||||
effectAi:
|
||||
'Bestuurt het apparaat via een lokale interface die de andere rechten niet beperken, dus dit staat gelijk aan volledige apparaatcontrole.',
|
||||
fullControlWarning: 'Deze rechten komen neer op volledige apparaatcontrole:',
|
||||
emptyWarning: 'Dit account heeft dan geen enkele functie en kan alleen inloggen.',
|
||||
impliedBy: 'Inbegrepen via {{name}}',
|
||||
grantCount_one: '{{count}} recht',
|
||||
grantCount: '{{count}} rechten',
|
||||
adminAll: 'Beheerders hebben alle rechten',
|
||||
catalogFailed:
|
||||
'De rechtenlijst kon niet worden geladen. Sluit dit venster en probeer het opnieuw.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const pl = {
|
||||
login: 'Logowanie',
|
||||
changePassword: 'Zmień Hasło',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Obrazy i udostępnianie plików'
|
||||
},
|
||||
auth: {
|
||||
login: 'Logowanie',
|
||||
@@ -40,7 +41,10 @@ const pl = {
|
||||
change2: 'Hasło logowania web',
|
||||
change3: 'Hasło roota systemu (hasło logowania SSH)',
|
||||
change4: 'Aby zresetować hasła, naciśnij i przytrzymaj przycisk BOOT na NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Bieżące hasło',
|
||||
passwordLength: 'Hasło musi mieć od 8 do 72 znaków',
|
||||
invalidCurrentPassword: 'Bieżące hasło jest nieprawidłowe'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const pl = {
|
||||
verifyBtn: 'Sprawdź'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Tylko podgląd',
|
||||
readOnlyTip:
|
||||
'To konto nie może wysyłać danych z klawiatury ani myszy do maszyny docelowej, dlatego konsola tylko wyświetla ekran.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'To konto nie ma dostępu do konsoli',
|
||||
description:
|
||||
'Przyznano mu inne możliwości. Otwórz jedną ze stron, z których może korzystać, albo poproś administratora o dostęp do konsoli.',
|
||||
nothing: 'To konto nie ma jeszcze żadnych uprawnień. Poproś administratora o ich przyznanie.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Ekran',
|
||||
@@ -215,33 +230,43 @@ const pl = {
|
||||
disable: 'Wyłącz tryb HID-Tylko'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Obrazy',
|
||||
loading: 'Ładowanie...',
|
||||
empty: 'Nic nie znaleziono',
|
||||
storage: {
|
||||
title: 'Obrazy i udostępnianie plików',
|
||||
empty:
|
||||
'Nie ma jeszcze obrazów. Prześlij plik ISO lub IMG albo skopiuj go na dysk ze zdalnego komputera.',
|
||||
notAttached: 'Nic nie jest zamontowane',
|
||||
attached: 'Zamontowano: {{name}}',
|
||||
eject: 'Wysuń',
|
||||
mount: 'Zamontuj',
|
||||
mountMode: 'Tryb montowania',
|
||||
mountFailed: 'Nie udało się zamontować obrazu',
|
||||
mountDesc:
|
||||
'W niektórych systemach wymagane jest wyjęcie dysku wirtualnego na zdalnym hoście przed zamontowaniem obrazu.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Odmontowanie nie powiodło się',
|
||||
unmountDesc:
|
||||
'W niektórych systemach należy ręcznie wysunąć obraz ze zdalnego hosta przed odmontowaniem obrazu.',
|
||||
refresh: 'Odśwież listę obrazów',
|
||||
attention: 'Uwaga',
|
||||
deleteConfirm: 'Czy na pewno chcesz usunąć to zdjęcie?',
|
||||
okBtn: 'Tak',
|
||||
cancelBtn: 'Nie',
|
||||
mountModeDisk: 'Pamięć masowa',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Otwórz menedżer plików',
|
||||
refresh: 'Odśwież',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usuń nośnik',
|
||||
deleteConfirm: 'Usunąć {{name}}? Znajdujące się w nim pliki zostaną utracone.',
|
||||
downloadImage: 'Pobierz obraz',
|
||||
typePartition: 'Udostępnione pliki',
|
||||
volumeLabel: 'Dysk wymienny',
|
||||
groups: {
|
||||
share: 'Udostępnianie plików',
|
||||
images: 'Obrazy'
|
||||
},
|
||||
failed: 'Operacja nie powiodła się',
|
||||
hidOnly: 'Tryb tylko HID: dysk wirtualny jest niedostępny',
|
||||
disabled: 'Dysk wirtualny jest wyłączony (Ustawienia - Urządzenie)',
|
||||
force: {
|
||||
title: 'Odebrać nośnik siłą?',
|
||||
desc: 'Zdalny komputer nadal używa tego nośnika. Odebranie go ponownie wyliczy urządzenie USB i może utracić wszystko, czego jeszcze nie zapisało na dysku.',
|
||||
menuHint: 'Zdalny komputer używa tego nośnika. Otwórz menedżer plików, aby odebrać go siłą.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Jak przesłać obrazy',
|
||||
usb1: 'Podłącz urządzenie NanoKVM do komputera przez USB.',
|
||||
usb2: 'Upewnij się, że dysk wirtualny jest zamontowany (Ustawienia - Dysk wirtualny).',
|
||||
usb2: 'Na tej stronie zamontuj udostępnione pliki w trybie pamięci masowej.',
|
||||
usb3: 'Otwórz dysk wirtualny na swoim komputerze i skopiuj plik obrazu do katalogu głównego dysku wirtualnego.',
|
||||
usb4: 'Po zakończeniu kopiowania wysuń dysk: obraz pojawi się wtedy na liście.',
|
||||
scp1: 'Upewnij się że NanoKVM i twój komputer są na tej samej sieci lokalnej.',
|
||||
scp2: 'Otwórz terminal na komputerze i użyj komendę SCP aby przesłać obraz do katalogu /data na NanoKVM.',
|
||||
scp3: 'Przykład: scp lokalizacja-zrodlowego-obrazu root@ip-twojego-nanokvm:/data',
|
||||
@@ -251,7 +276,69 @@ const pl = {
|
||||
tf3: 'Włóż kartę SD do czytnika kart i podłącz do twojego komputera.',
|
||||
tf4: 'Kopjuj obraz do katalogu /data na karcie SD.',
|
||||
tf5: 'Włóż kartę SD do NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Przetwarzanie {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Włączyć dysk wirtualny?',
|
||||
desc: 'Do zamontowania nośnika potrzebny jest dysk wirtualny, który jest teraz wyłączony. Włączyć go? Klawiatura i mysz zdalnego komputera rozłączą się na chwilę.',
|
||||
menuHint: 'Dysk wirtualny jest wyłączony. Włącz go w Ustawienia - Urządzenie.',
|
||||
askDevice:
|
||||
'Dysk wirtualny jest wyłączony. Najpierw musi go włączyć konto z uprawnieniem do urządzenia.'
|
||||
},
|
||||
add: {
|
||||
title: 'Dodaj nośnik',
|
||||
upload: 'Prześlij plik ISO lub IMG',
|
||||
fromUrl: 'Pobierz z adresu URL',
|
||||
url: 'Adres URL obrazu',
|
||||
urlRequired: 'Podaj adres URL',
|
||||
onlyImages: 'Można montować tylko pliki .iso i .img',
|
||||
dropHint: 'Przeciągnij tutaj plik ISO lub IMG albo kliknij, aby go wybrać',
|
||||
cdromOnlyIso: 'CD-ROM może zawierać wyłącznie obraz ISO 9660',
|
||||
uploadFailed: 'Przesyłanie nie powiodło się',
|
||||
downloadFailed: 'Pobieranie nie powiodło się',
|
||||
uploading: 'Przesyłanie {{name}}',
|
||||
downloading: 'Pobieranie {{name}}',
|
||||
done: '{{name}} jest gotowy',
|
||||
checksumFailed: 'Obraz nie zgadza się z sumą kontrolną SHA-256.',
|
||||
blocked: 'Zdalny komputer używa dysku',
|
||||
copyHint:
|
||||
'Obraz możesz też skopiować do partycji danych przez SCP albo ze zdalnego komputera.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Wybierz nośnik, aby przeglądać znajdujące się na nim pliki.',
|
||||
reasonAttached: 'Ten nośnik jest podłączony do zdalnego komputera.',
|
||||
reasonAttachedHint:
|
||||
'Najpierw go wysuń: zdalny komputer musi zwolnić dysk, zanim będzie można odczytać jego pliki.',
|
||||
reasonNotBrowsable: 'Ten nośnik zawiera system plików, którego menedżer nie może otworzyć.',
|
||||
reasonNoLoop: 'To urządzenie nie może montować obrazów dysków (brak obsługi loop).',
|
||||
reasonISOImage:
|
||||
'Obrazu ISO 9660 nie można tu przeglądać. Nadal można go zamontować na zdalnym komputerze.',
|
||||
empty: 'Ten folder jest pusty',
|
||||
noMatch: 'Żaden plik nie pasuje do wyszukiwania.',
|
||||
free: '{{size}} wolne',
|
||||
search: 'Szukaj w tym folderze',
|
||||
sort: {
|
||||
name: 'Nazwa',
|
||||
size: 'Rozmiar',
|
||||
type: 'Typ',
|
||||
asc: 'Rosnąco',
|
||||
desc: 'Malejąco'
|
||||
},
|
||||
upload: 'Prześlij plik na nośnik',
|
||||
deleteFolderTitle: 'Usuń folder',
|
||||
deleteFolderConfirm:
|
||||
'Usunąć folder {{name}} wraz z całą zawartością? Tej operacji nie można cofnąć.',
|
||||
uploading: 'Przesyłanie…',
|
||||
uploadFailed: 'Przesyłanie nie powiodło się',
|
||||
download: 'Pobierz',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usuń plik',
|
||||
deleteConfirm: 'Usunąć {{name}} z nośnika?'
|
||||
},
|
||||
yes: 'Tak',
|
||||
no: 'Nie'
|
||||
},
|
||||
script: {
|
||||
title: 'Skrypty',
|
||||
@@ -293,21 +380,11 @@ const pl = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Narzędzie do pobierania obrazów',
|
||||
input: 'Proszę wprowadzić zdalny obraz URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partycja to RO, więc nie możemy pobrać obrazu',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Upuść plik tutaj lub kliknij, aby wybrać',
|
||||
inputfile: 'Proszę wprowadzić plik obrazu',
|
||||
NoISO: 'Brak ISO',
|
||||
disabled:
|
||||
'Partycja danych jest tylko do odczytu, dopóki używa jej zdalny komputer: nie można zapisać nowych obrazów, ale istniejące pliki nadal można pobierać.',
|
||||
sha256: 'SHA-256 (opcjonalnie)',
|
||||
sha256Placeholder: 'Wprowadź 64-znakową sumę kontrolną SHA-256',
|
||||
invalidSHA256: 'SHA-256 musi być 64-znakowym ciągiem szesnastkowym',
|
||||
failed: 'Pobieranie nie powiodło się',
|
||||
success: 'Pobieranie zakończone pomyślnie',
|
||||
checksumFailed: 'Pobieranie nie powiodło się: weryfikacja SHA-256 nie powiodła się',
|
||||
cancel: 'Anuluj',
|
||||
cancelFailed: 'Nie udało się anulować pobierania'
|
||||
},
|
||||
@@ -458,16 +535,18 @@ const pl = {
|
||||
'Przestań emulować urządzenia wirtualne, zachowując jedynie podstawową kontrolę HID',
|
||||
disk: 'Dysk wirtualny',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Sieć wirtualna',
|
||||
networkDesc: 'Zamontuj wirtualną kartę sieciową na zdalnym hoście',
|
||||
reboot: 'Uruchom ponownie',
|
||||
rebootDesc: 'Czy na pewno chcesz ponownie uruchomić NanoKVM?',
|
||||
okBtn: 'Tak',
|
||||
cancelBtn: 'Nie'
|
||||
cancelBtn: 'Nie',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Uwaga',
|
||||
updateFailed: 'Nie udało się zaktualizować urządzenia wirtualnego'
|
||||
},
|
||||
network: {
|
||||
title: 'Sieć',
|
||||
@@ -606,7 +685,90 @@ const pl = {
|
||||
logoutBtn: 'Wyloguj',
|
||||
logoutDesc: 'Czy na pewno chcesz się wylogować?',
|
||||
okBtn: 'Tak',
|
||||
cancelBtn: 'Nie'
|
||||
cancelBtn: 'Nie',
|
||||
role: 'Rola',
|
||||
roles: {
|
||||
admin: 'Administrator',
|
||||
user: 'Użytkownik'
|
||||
},
|
||||
users: {
|
||||
title: 'Użytkownicy',
|
||||
create: 'Utwórz użytkownika',
|
||||
enabled: 'Włączony',
|
||||
disabled: 'Wyłączony',
|
||||
deviceOwner: 'Właściciel urządzenia',
|
||||
resetPassword: 'Zresetuj hasło',
|
||||
delete: 'Usuń',
|
||||
deleteConfirm: 'Usunąć tego użytkownika i unieważnić wszystkie jego sesje?',
|
||||
created: 'Utworzono użytkownika',
|
||||
deleted: 'Usunięto użytkownika',
|
||||
passwordUpdated: 'Zaktualizowano hasło',
|
||||
loadFailed: 'Nie udało się wczytać użytkowników',
|
||||
saveFailed: 'Nie udało się zapisać użytkownika',
|
||||
deleteFailed: 'Nie udało się usunąć użytkownika',
|
||||
editPermissions: 'Uprawnienia',
|
||||
mcpLossTitle: 'Odbierz dostęp MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} utraci dostęp MCP. Klucz API MCP jest wspólny dla całego urządzenia, więc jego kopia działa, dopóki klucz nie zostanie wygenerowany ponownie. Ponowne wygenerowanie rozłącza wszystkich klientów MCP, dopóki nie otrzymają nowego klucza.',
|
||||
mcpLossRotate: 'Wygeneruj ponownie klucz API MCP',
|
||||
mcpLossKeep:
|
||||
'Obecny klucz pozostaje ważny, a każdy, kto ma kopię, nadal może sterować klawiaturą i myszą. Możesz go wygenerować ponownie później w ustawieniach MCP.',
|
||||
mcpKeyRotated:
|
||||
'Klucz API MCP wygenerowany ponownie. Klienci MCP potrzebują nowego klucza.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Uprawnienia',
|
||||
editTitle: 'Edytuj uprawnienia',
|
||||
save: 'Zapisz',
|
||||
saved: 'Zapisano',
|
||||
close: 'Zamknij',
|
||||
groupConsole: 'Konsola',
|
||||
groupTarget: 'Maszyna docelowa',
|
||||
groupLibrary: 'Biblioteka nośników',
|
||||
groupHost: 'To urządzenie',
|
||||
groupServices: 'Usługi',
|
||||
consoleView: 'Podgląd ekranu',
|
||||
consoleControl: 'Klawiatura i mysz',
|
||||
power: 'Zasilanie i wybudzanie',
|
||||
mediumAttach: 'Podłącz nośnik',
|
||||
libraryRead: 'Przeglądaj bibliotekę',
|
||||
libraryWrite: 'Zarządzaj biblioteką',
|
||||
terminal: 'Terminal i skrypty',
|
||||
device: 'Ustawienia urządzenia',
|
||||
network: 'Ustawienia sieci',
|
||||
update: 'Aktualizacja systemu',
|
||||
mcp: 'Usługa MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Asystent AI',
|
||||
effectConsoleView:
|
||||
'Pokazuje ekran maszyny docelowej i ustawia tryb wideo, rozdzielczość, liczbę klatek i jakość wspólne dla wszystkich widzów.',
|
||||
effectConsoleControl: 'Wysyła wejście z klawiatury i myszy do maszyny docelowej.',
|
||||
effectPower:
|
||||
'Włącza, wyłącza i resetuje maszynę docelową oraz zarządza listą urządzeń Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Montuje i odłącza nośniki na maszynie docelowej oraz wymienia z nią pliki przez współdzieloną partycję.',
|
||||
effectLibraryRead: 'Wyświetla i pobiera obrazy oraz pliki w nich zawarte.',
|
||||
effectLibraryWrite: 'Wysyła, tworzy i usuwa obrazy oraz zmienia pliki w nich zawarte.',
|
||||
effectTerminal: 'Daje powłokę root na tym urządzeniu, z pominięciem sesji webowej.',
|
||||
effectDevice:
|
||||
'Umożliwia włączenie SSH (również root), restart urządzenia, udostępnienie surowej partycji danych maszynie docelowej oraz zmianę ustawień OLED, HDMI, mDNS, pamięci i swap.',
|
||||
effectNetwork: 'Zmienia Wi-Fi, DNS i TLS, co może odciąć to urządzenie od sieci.',
|
||||
effectUpdate: 'Instaluje firmware i pakiety aplikacji.',
|
||||
effectMcp:
|
||||
'Wydaje klucz API obejmujący całe urządzenie, który steruje klawiaturą, myszą i zrzutami ekranu poza sesją webową; kopia działa po odebraniu uprawnienia, chyba że klucz zostanie wygenerowany ponownie, co jest proponowane przy usuwaniu uprawnienia.',
|
||||
effectTailscale: 'Dołącza to urządzenie do sieci VPN typu mesh.',
|
||||
effectAi:
|
||||
'Steruje urządzeniem przez lokalny interfejs, którego pozostałe uprawnienia nie ograniczają, więc oznacza to pełną kontrolę nad urządzeniem.',
|
||||
fullControlWarning: 'Te uprawnienia oznaczają pełną kontrolę nad urządzeniem:',
|
||||
emptyWarning:
|
||||
'To konto nie będzie miało żadnych funkcji i będzie mogło się tylko zalogować.',
|
||||
impliedBy: 'Zawarte przez {{name}}',
|
||||
grantCount_one: '{{count}} uprawnienie',
|
||||
grantCount: '{{count}} uprawnień',
|
||||
adminAll: 'Administratorzy mają wszystkie uprawnienia',
|
||||
catalogFailed:
|
||||
'Nie udało się wczytać listy uprawnień. Zamknij to okno i spróbuj ponownie.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const pt_br = {
|
||||
login: 'Login',
|
||||
changePassword: 'Mudar Senha',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Imagens e compartilhamento de arquivos'
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
@@ -39,7 +40,10 @@ const pt_br = {
|
||||
change2: 'Senha de login da Web',
|
||||
change3: 'Senha root do sistema (senha de login SSH)',
|
||||
change4: 'Para redefinir as senhas, pressione e segure o botão BOOT no NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Senha atual',
|
||||
passwordLength: 'A senha deve ter entre 8 e 72 caracteres',
|
||||
invalidCurrentPassword: 'A senha atual está incorreta'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const pt_br = {
|
||||
verifyBtn: 'Verificar'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Somente leitura',
|
||||
readOnlyTip:
|
||||
'Esta conta não pode enviar entrada de teclado ou mouse para a máquina de destino, portanto o console apenas exibe a tela.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Esta conta não tem acesso ao console',
|
||||
description:
|
||||
'Foram concedidas outras capacidades. Abra uma das páginas que ela pode usar ou peça a um administrador acesso ao console.',
|
||||
nothing: 'Esta conta ainda não tem permissões. Peça a um administrador para conceder.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Escala',
|
||||
title: 'Tela',
|
||||
@@ -214,33 +229,44 @@ const pt_br = {
|
||||
disable: 'Desabilitar modo Somente-HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Imagens',
|
||||
loading: 'Carregando...',
|
||||
empty: 'Nada Encontrado',
|
||||
storage: {
|
||||
title: 'Imagens e compartilhamento de arquivos',
|
||||
empty:
|
||||
'Ainda não há imagens. Envie uma ISO ou IMG, ou copie uma para o disco a partir da máquina remota.',
|
||||
notAttached: 'Nada montado',
|
||||
attached: 'Montado: {{name}}',
|
||||
eject: 'Ejetar',
|
||||
mount: 'Montar',
|
||||
mountMode: 'Modo de montagem',
|
||||
mountFailed: 'Falha na Montagem',
|
||||
mountDesc:
|
||||
'Em alguns sistemas, é necessário ejetar o disco virtual no host remoto antes de montar a imagem.',
|
||||
diskOnWarn:
|
||||
'Montar a imagem desliga o disco virtual, então o computador não pode mais acessar /data. Continuar?',
|
||||
diskOffWarn:
|
||||
'O disco virtual está desligado e precisa ser ativado para montar a imagem. Os dispositivos USB do computador são reconectados. Continuar?',
|
||||
diskShared:
|
||||
'O disco virtual está ativado, então /data é somente leitura aqui. Desative-o para excluir ou enviar imagens.',
|
||||
unmountFailed: 'Falha na desmontagem',
|
||||
unmountDesc:
|
||||
'Em alguns sistemas, é necessário ejetar manualmente do host remoto antes de desmontar a imagem.',
|
||||
refresh: 'Atualizar a lista de imagens',
|
||||
attention: 'Atenção',
|
||||
deleteConfirm: 'Tem certeza que deseja excluir esta imagem?',
|
||||
okBtn: 'Sim',
|
||||
cancelBtn: 'Não',
|
||||
mountModeDisk: 'Armazenamento em massa',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Abrir o gerenciador de arquivos',
|
||||
refresh: 'Atualizar',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir mídia',
|
||||
deleteConfirm: 'Excluir {{name}}? Os arquivos dentro dele serão perdidos.',
|
||||
downloadImage: 'Baixar a imagem',
|
||||
typePartition: 'Arquivos compartilhados',
|
||||
volumeLabel: 'Disco removível',
|
||||
groups: {
|
||||
share: 'Compartilhamento de arquivos',
|
||||
images: 'Imagens'
|
||||
},
|
||||
failed: 'A operação falhou',
|
||||
hidOnly: 'Modo somente HID: o disco virtual não está disponível',
|
||||
disabled: 'O disco virtual está desativado (Configurações - Dispositivo)',
|
||||
force: {
|
||||
title: 'Retomar a mídia à força?',
|
||||
desc: 'A máquina remota ainda está usando esta mídia. Retomá-la reenumera o dispositivo USB e pode perder tudo o que ela ainda não gravou no disco.',
|
||||
menuHint:
|
||||
'A máquina remota está usando esta mídia. Abra o gerenciador de arquivos para retomá-la à força.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Como fazer upload',
|
||||
usb1: 'Conecte o NanoKVM ao seu computador via USB.',
|
||||
usb2: 'Certifique-se de que o disco virtual está montado (Configurações - Disco Virtual).',
|
||||
usb2: 'Nesta página, monte os arquivos compartilhados no modo de armazenamento em massa.',
|
||||
usb3: 'Abra o disco virtual no seu computador e copie o arquivo de imagem para o diretório raiz do disco virtual.',
|
||||
usb4: 'Ejete o disco quando a cópia terminar: a imagem aparecerá na lista.',
|
||||
scp1: 'Certifique-se de que o NanoKVM e seu computador estão na mesma rede local.',
|
||||
scp2: 'Abra um terminal no seu computador e use o comando SCP para fazer upload do arquivo de imagem para o diretório /data no NanoKVM.',
|
||||
scp3: 'Exemplo: scp seu-caminho-da-imagem root@seu-ip-nanokvm:/data',
|
||||
@@ -250,7 +276,70 @@ const pt_br = {
|
||||
tf3: 'Insira o cartão TF em um leitor de cartão e conecte-o ao seu computador.',
|
||||
tf4: 'Copie o arquivo de imagem para o diretório /data no cartão TF.',
|
||||
tf5: 'Insira o cartão TF no NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Processando {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Ativar o disco virtual?',
|
||||
desc: 'Montar uma mídia exige o disco virtual, que está desativado no momento. Ativá-lo agora? O teclado e o mouse da máquina remota serão desconectados por um instante.',
|
||||
menuHint: 'O disco virtual está desativado. Ative-o em Configurações - Dispositivo.',
|
||||
askDevice:
|
||||
'O disco virtual está desativado. Uma conta com a permissão de dispositivo precisa ativá-lo primeiro.'
|
||||
},
|
||||
add: {
|
||||
title: 'Adicionar mídia',
|
||||
upload: 'Enviar uma ISO ou IMG',
|
||||
fromUrl: 'Baixar de uma URL',
|
||||
url: 'URL da imagem',
|
||||
urlRequired: 'Informe uma URL',
|
||||
onlyImages: 'Só é possível montar arquivos .iso e .img',
|
||||
dropHint: 'Arraste uma ISO ou IMG aqui, ou clique para escolher um arquivo',
|
||||
cdromOnlyIso: 'Um CD-ROM só pode conter uma imagem ISO 9660',
|
||||
uploadFailed: 'Falha no envio',
|
||||
downloadFailed: 'Falha no download',
|
||||
uploading: 'Enviando {{name}}',
|
||||
downloading: 'Baixando {{name}}',
|
||||
done: '{{name}} está pronto',
|
||||
checksumFailed: 'A imagem não corresponde ao checksum SHA-256.',
|
||||
blocked: 'A máquina remota está usando o disco',
|
||||
copyHint:
|
||||
'Você também pode copiar uma imagem para a partição de dados via SCP, ou a partir da máquina remota.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Selecione uma mídia para ver os arquivos dentro dela.',
|
||||
reasonAttached: 'Esta mídia está conectada à máquina remota.',
|
||||
reasonAttachedHint:
|
||||
'Ejete-a primeiro: a máquina remota precisa liberar o disco antes que seus arquivos possam ser lidos.',
|
||||
reasonNotBrowsable:
|
||||
'Esta mídia contém um sistema de arquivos que o gerenciador não consegue abrir.',
|
||||
reasonNoLoop: 'Este dispositivo não consegue montar imagens de disco (sem suporte a loop).',
|
||||
reasonISOImage:
|
||||
'Uma imagem ISO 9660 não pode ser navegada aqui. Ela ainda pode ser montada na máquina remota.',
|
||||
empty: 'Esta pasta está vazia',
|
||||
noMatch: 'Nenhum arquivo corresponde à busca.',
|
||||
free: '{{size}} livres',
|
||||
search: 'Buscar nesta pasta',
|
||||
sort: {
|
||||
name: 'Nome',
|
||||
size: 'Tamanho',
|
||||
type: 'Tipo',
|
||||
asc: 'Crescente',
|
||||
desc: 'Decrescente'
|
||||
},
|
||||
upload: 'Enviar um arquivo para a mídia',
|
||||
deleteFolderTitle: 'Excluir pasta',
|
||||
deleteFolderConfirm:
|
||||
'Excluir a pasta {{name}} e todo o seu conteúdo? Esta ação não pode ser desfeita.',
|
||||
uploading: 'Enviando…',
|
||||
uploadFailed: 'Falha no envio',
|
||||
download: 'Baixar',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir arquivo',
|
||||
deleteConfirm: 'Excluir {{name}} da mídia?'
|
||||
},
|
||||
yes: 'Sim',
|
||||
no: 'Não'
|
||||
},
|
||||
script: {
|
||||
title: 'Scripts',
|
||||
@@ -292,21 +381,11 @@ const pt_br = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Baixador de Imagens',
|
||||
input: 'Por favor, digite uma URL de imagem remota',
|
||||
ok: 'Ok',
|
||||
disabled: 'A partição /data é RO, então não podemos baixar a imagem',
|
||||
diskShared: 'Desligue primeiro o disco virtual e depois use o download de imagens.',
|
||||
diskOff: 'Desligar o disco virtual',
|
||||
uploadbox: 'Solte o arquivo aqui ou clique para selecionar',
|
||||
inputfile: 'Por favor insira o arquivo de imagem',
|
||||
NoISO: 'Sem ISO',
|
||||
disabled:
|
||||
'A partição de dados fica somente leitura enquanto a máquina remota a usa: novas imagens não podem ser gravadas, mas os arquivos existentes continuam podendo ser baixados.',
|
||||
sha256: 'SHA-256 (opcional)',
|
||||
sha256Placeholder: 'Digite um checksum SHA-256 de 64 caracteres',
|
||||
invalidSHA256: 'SHA-256 deve ser uma sequência hexadecimal de 64 caracteres',
|
||||
failed: 'Falha no download',
|
||||
success: 'Download concluído',
|
||||
checksumFailed: 'Falha no download: a verificação SHA-256 falhou',
|
||||
cancel: 'Cancelar',
|
||||
cancelFailed: 'Falha ao cancelar o download'
|
||||
},
|
||||
@@ -456,16 +535,18 @@ const pt_br = {
|
||||
hidOnlyDesc: 'Pare de emular dispositivos virtuais, mantendo apenas o controle básico HID',
|
||||
disk: 'Disco Virtual',
|
||||
diskDesc: 'Montar U-disk virtual no host remoto',
|
||||
diskLocked: 'Desligado enquanto uma imagem estiver montada (ligá-lo cancela a montagem)',
|
||||
diskCancelMount:
|
||||
'Ativar o disco virtual cancela a imagem montada e interrompe um download ou upload de imagem em andamento. Continuar?',
|
||||
diskBreakDownload: 'Ativar o disco virtual interrompe o download de imagens. Continuar?',
|
||||
network: 'Rede Virtual',
|
||||
networkDesc: 'Montar placa de rede virtual no host remoto',
|
||||
reboot: 'Reiniciar',
|
||||
rebootDesc: 'Tem certeza de que deseja reiniciar o NanoKVM?',
|
||||
okBtn: 'Sim',
|
||||
cancelBtn: 'Não'
|
||||
cancelBtn: 'Não',
|
||||
diskLocked: 'Desligado enquanto uma imagem estiver montada (ligá-lo cancela a montagem)',
|
||||
diskCancelMount:
|
||||
'Ativar o disco virtual cancela a imagem montada e interrompe um download ou upload de imagem em andamento. Continuar?',
|
||||
diskBreakDownload: 'Ativar o disco virtual interrompe o download de imagens. Continuar?',
|
||||
attention: 'Atenção',
|
||||
updateFailed: 'Falha ao atualizar o dispositivo virtual'
|
||||
},
|
||||
network: {
|
||||
title: 'Rede',
|
||||
@@ -604,7 +685,88 @@ const pt_br = {
|
||||
logoutBtn: 'Sair',
|
||||
logoutDesc: 'Tem certeza de que deseja sair?',
|
||||
okBtn: 'Sim',
|
||||
cancelBtn: 'Não'
|
||||
cancelBtn: 'Não',
|
||||
role: 'Função',
|
||||
roles: {
|
||||
admin: 'Administrador',
|
||||
user: 'Usuário'
|
||||
},
|
||||
users: {
|
||||
title: 'Usuários',
|
||||
create: 'Criar usuário',
|
||||
enabled: 'Ativado',
|
||||
disabled: 'Desativado',
|
||||
deviceOwner: 'Proprietário do dispositivo',
|
||||
resetPassword: 'Redefinir senha',
|
||||
delete: 'Excluir',
|
||||
deleteConfirm: 'Excluir este usuário e revogar todas as sessões dele?',
|
||||
created: 'Usuário criado',
|
||||
deleted: 'Usuário excluído',
|
||||
passwordUpdated: 'Senha atualizada',
|
||||
loadFailed: 'Falha ao carregar os usuários',
|
||||
saveFailed: 'Falha ao salvar o usuário',
|
||||
deleteFailed: 'Falha ao excluir o usuário',
|
||||
editPermissions: 'Permissões',
|
||||
mcpLossTitle: 'Revogar acesso MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} perderá o acesso MCP. A chave de API do MCP é compartilhada por todo o dispositivo, então uma cópia continua funcionando até a chave ser regenerada. Regenerá-la desconecta todos os clientes MCP até que recebam a nova chave.',
|
||||
mcpLossRotate: 'Regenerar a chave de API do MCP',
|
||||
mcpLossKeep:
|
||||
'A chave atual continua válida, e quem tiver uma cópia ainda pode controlar o teclado e o mouse. Você pode regenerá-la depois nas configurações do MCP.',
|
||||
mcpKeyRotated: 'Chave de API do MCP regenerada. Os clientes MCP precisam da nova chave.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Permissões',
|
||||
editTitle: 'Editar permissões',
|
||||
save: 'Salvar',
|
||||
saved: 'Salvo',
|
||||
close: 'Fechar',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Máquina de destino',
|
||||
groupLibrary: 'Biblioteca de mídia',
|
||||
groupHost: 'Este dispositivo',
|
||||
groupServices: 'Serviços',
|
||||
consoleView: 'Ver a tela',
|
||||
consoleControl: 'Teclado e mouse',
|
||||
power: 'Energia e despertar',
|
||||
mediumAttach: 'Conectar uma mídia',
|
||||
libraryRead: 'Navegar na biblioteca',
|
||||
libraryWrite: 'Gerenciar a biblioteca',
|
||||
terminal: 'Terminal e scripts',
|
||||
device: 'Configurações do dispositivo',
|
||||
network: 'Configurações de rede',
|
||||
update: 'Atualização do sistema',
|
||||
mcp: 'Serviço MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Assistente de IA',
|
||||
effectConsoleView:
|
||||
'Mostra a tela de destino e define o modo de vídeo, a resolução, a taxa de quadros e a qualidade compartilhados por todos os espectadores.',
|
||||
effectConsoleControl: 'Envia entrada de teclado e mouse para a máquina de destino.',
|
||||
effectPower:
|
||||
'Liga, desliga e reinicia a máquina de destino, e gerencia a lista de dispositivos Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Monta e ejeta mídias na máquina de destino, e troca arquivos com ela pela partição compartilhada.',
|
||||
effectLibraryRead: 'Lista e baixa as imagens e os arquivos dentro delas.',
|
||||
effectLibraryWrite: 'Envia, cria e exclui imagens, e altera os arquivos dentro delas.',
|
||||
effectTerminal: 'Fornece um shell root neste dispositivo, sem passar pela sessão web.',
|
||||
effectDevice:
|
||||
'Permite ativar SSH (também root), reiniciar o dispositivo, entregar a partição de dados bruta à máquina de destino e alterar OLED, HDMI, mDNS, memória e swap.',
|
||||
effectNetwork: 'Altera Wi-Fi, DNS e TLS, o que pode tirar este dispositivo da rede.',
|
||||
effectUpdate: 'Instala firmware e pacotes de aplicativo.',
|
||||
effectMcp:
|
||||
'Emite uma chave de API de todo o dispositivo que controla teclado, mouse e capturas fora da sessão web; uma cópia continua válida após revogar a permissão, a menos que a chave seja regenerada, o que é oferecido ao remover a permissão.',
|
||||
effectTailscale: 'Conecta este dispositivo a uma malha VPN.',
|
||||
effectAi:
|
||||
'Controla o dispositivo por uma interface local que as outras permissões não limitam, então equivale ao controle total do dispositivo.',
|
||||
fullControlWarning: 'Estas permissões equivalem ao controle total do dispositivo:',
|
||||
emptyWarning: 'Esta conta não terá nenhuma função e só poderá entrar.',
|
||||
impliedBy: 'Incluído por {{name}}',
|
||||
grantCount_one: '{{count}} permissão',
|
||||
grantCount: '{{count}} permissões',
|
||||
adminAll: 'Administradores têm todas as permissões',
|
||||
catalogFailed:
|
||||
'Não foi possível carregar a lista de permissões. Feche esta janela e tente novamente.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const ru = {
|
||||
login: 'Войти',
|
||||
changePassword: 'Изменить пароль',
|
||||
terminal: 'Терминал',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Образы и обмен файлами'
|
||||
},
|
||||
auth: {
|
||||
login: 'Войти',
|
||||
@@ -39,7 +40,10 @@ const ru = {
|
||||
change2: 'Пароль для входа в веб-интерфейс',
|
||||
change3: 'Пароль системного пользователя "root" (пароль для входа по SSH)',
|
||||
change4: 'Чтобы сбросить пароли, нажмите и удерживайте кнопку BOOT на NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Текущий пароль',
|
||||
passwordLength: 'Пароль должен содержать от 8 до 72 символов',
|
||||
invalidCurrentPassword: 'Текущий пароль неверен'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const ru = {
|
||||
verifyBtn: 'Проверить'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Только просмотр',
|
||||
readOnlyTip:
|
||||
'Этот аккаунт не может передавать ввод с клавиатуры и мыши на целевую машину, поэтому консоль только показывает экран.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'У этой учётной записи нет доступа к консоли',
|
||||
description:
|
||||
'Ей выданы другие возможности. Откройте одну из доступных страниц или попросите администратора дать доступ к консоли.',
|
||||
nothing: 'У этой учётной записи пока нет разрешений. Попросите администратора их выдать.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Масштаб',
|
||||
title: 'Экран',
|
||||
@@ -214,33 +229,44 @@ const ru = {
|
||||
disable: 'Выключить режим только HID'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Образы',
|
||||
loading: 'Загрузка...',
|
||||
empty: 'Пусто',
|
||||
storage: {
|
||||
title: 'Образы и обмен файлами',
|
||||
empty:
|
||||
'Образов пока нет. Загрузите ISO или IMG либо скопируйте образ на диск с целевого компьютера.',
|
||||
notAttached: 'Ничего не подключено',
|
||||
attached: 'Подключено: {{name}}',
|
||||
eject: 'Извлечь',
|
||||
mount: 'Подключить',
|
||||
mountMode: 'Режим монтирования',
|
||||
mountFailed: 'Монтирование образа не удалось',
|
||||
mountDesc:
|
||||
'В некоторых системах необходимо отсоединить виртуальный диск на удаленном хосте перед монтированием образа.',
|
||||
diskOnWarn:
|
||||
'Монтирование образа отключает виртуальный диск, и компьютер теряет доступ к /data. Продолжить?',
|
||||
diskOffWarn:
|
||||
'Виртуальный диск отключён, его нужно включить для монтирования образа. USB-устройства компьютера будут переподключены. Продолжить?',
|
||||
diskShared:
|
||||
'Виртуальный диск включён, поэтому /data здесь только для чтения. Отключите его, чтобы удалять или загружать образы.',
|
||||
unmountFailed: 'Не удалось размонтировать',
|
||||
unmountDesc:
|
||||
'В некоторых системах перед размонтированием образа необходимо вручную извлечь образ с удаленного хоста.',
|
||||
refresh: 'Обновить список образов',
|
||||
attention: 'Внимание',
|
||||
deleteConfirm: 'Вы уверены, что хотите удалить это изображение?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет',
|
||||
mountModeDisk: 'Съёмный диск',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Открыть файловый менеджер',
|
||||
refresh: 'Обновить',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить носитель',
|
||||
deleteConfirm: 'Удалить {{name}}? Содержащиеся в нём файлы будут потеряны.',
|
||||
downloadImage: 'Скачать образ',
|
||||
typePartition: 'Общие файлы',
|
||||
volumeLabel: 'Съёмный диск',
|
||||
groups: {
|
||||
share: 'Обмен файлами',
|
||||
images: 'Образы'
|
||||
},
|
||||
failed: 'Операция не удалась',
|
||||
hidOnly: 'Режим только HID: виртуальный диск недоступен',
|
||||
disabled: 'Виртуальный диск отключён (Настройки - Устройство)',
|
||||
force: {
|
||||
title: 'Забрать носитель принудительно?',
|
||||
desc: 'Целевой компьютер всё ещё использует этот носитель. Принудительное изъятие заново перечислит USB-устройство и может потерять всё, что ещё не записано на диск.',
|
||||
menuHint:
|
||||
'Целевой компьютер использует этот носитель. Откройте файловый менеджер, чтобы забрать его принудительно.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Как загрузить',
|
||||
usb1: 'Подключите NanoKVM к вашему компьютеру через USB.',
|
||||
usb2: 'Убедитесь, что виртуальный диск подключен (Настройки - Виртуальный диск).',
|
||||
usb2: 'На этой странице подключите общие файлы в режиме накопителя.',
|
||||
usb3: 'Откройте виртуальный диск на вашем компьютере и скопируйте файл образа в корневой каталог диска.',
|
||||
usb4: 'После копирования извлеките диск: образ появится в списке.',
|
||||
scp1: 'Убедитесь что NanoKVM и ваш компьютер находятся в одной и той же сети.',
|
||||
scp2: 'Откройте терминал на вашем компьютере и используйте команду scp, чтобы загрузить файл образа в директорию /data, находящуюся на NanoKVM.',
|
||||
scp3: 'Например: scp путь-к-образу root@ip-вашего-nanokvm:/data',
|
||||
@@ -250,7 +276,68 @@ const ru = {
|
||||
tf3: 'Вставьте карту памяти в картридер и подключите его в свой компьютер.',
|
||||
tf4: 'Скопируйте файл образа в директорию /data на карте памяти.',
|
||||
tf5: 'Вставьте карту памяти в NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Обработка {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Включить виртуальный диск?',
|
||||
desc: 'Для подключения носителя нужен виртуальный диск, который сейчас выключен. Включить его? Клавиатура и мышь целевого компьютера на мгновение отключатся.',
|
||||
menuHint: 'Виртуальный диск отключён. Включите его в разделе Настройки - Устройство.',
|
||||
askDevice:
|
||||
'Виртуальный диск выключен. Сначала его должен включить аккаунт с разрешением на устройство.'
|
||||
},
|
||||
add: {
|
||||
title: 'Добавить носитель',
|
||||
upload: 'Загрузить ISO или IMG',
|
||||
fromUrl: 'Скачать по ссылке',
|
||||
url: 'URL образа',
|
||||
urlRequired: 'Введите URL',
|
||||
onlyImages: 'Подключать можно только файлы .iso и .img',
|
||||
dropHint: 'Перетащите сюда ISO или IMG либо нажмите, чтобы выбрать файл',
|
||||
cdromOnlyIso: 'На CD-ROM можно записать только образ ISO 9660',
|
||||
uploadFailed: 'Не удалось загрузить',
|
||||
downloadFailed: 'Не удалось скачать',
|
||||
uploading: 'Загрузка {{name}}',
|
||||
downloading: 'Скачивание {{name}}',
|
||||
done: '{{name}} готов',
|
||||
checksumFailed: 'Образ не совпадает с контрольной суммой SHA-256.',
|
||||
blocked: 'Целевой компьютер использует диск',
|
||||
copyHint: 'Образ можно также скопировать в раздел данных по SCP или с целевого компьютера.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Выберите носитель, чтобы просмотреть файлы на нём.',
|
||||
reasonAttached: 'Этот носитель подключён к целевому компьютеру.',
|
||||
reasonAttachedHint:
|
||||
'Сначала извлеките его: целевой компьютер должен освободить диск, прежде чем его файлы можно будет прочитать.',
|
||||
reasonNotBrowsable: 'На этом носителе файловая система, которую менеджер не может открыть.',
|
||||
reasonNoLoop: 'Это устройство не может подключать образы дисков (нет поддержки loop).',
|
||||
reasonISOImage:
|
||||
'Содержимое образа ISO 9660 здесь не отображается. Сам образ по-прежнему можно подключить к целевому компьютеру.',
|
||||
empty: 'Эта папка пуста',
|
||||
noMatch: 'Ни один файл не соответствует поиску.',
|
||||
free: '{{size}} свободно',
|
||||
search: 'Искать в этой папке',
|
||||
sort: {
|
||||
name: 'Имя',
|
||||
size: 'Размер',
|
||||
type: 'Тип',
|
||||
asc: 'По возрастанию',
|
||||
desc: 'По убыванию'
|
||||
},
|
||||
upload: 'Загрузить файл на носитель',
|
||||
deleteFolderTitle: 'Удалить папку',
|
||||
deleteFolderConfirm:
|
||||
'Удалить папку {{name}} со всем содержимым? Это действие нельзя отменить.',
|
||||
uploading: 'Загрузка…',
|
||||
uploadFailed: 'Не удалось загрузить',
|
||||
download: 'Скачать',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить файл',
|
||||
deleteConfirm: 'Удалить {{name}} с носителя?'
|
||||
},
|
||||
yes: 'Да',
|
||||
no: 'Нет'
|
||||
},
|
||||
script: {
|
||||
title: 'Скрипты',
|
||||
@@ -292,21 +379,11 @@ const ru = {
|
||||
ok: 'ОК'
|
||||
},
|
||||
download: {
|
||||
title: 'Скачать образ',
|
||||
input: 'Введите URL удаленного образа',
|
||||
ok: 'OK',
|
||||
disabled: 'Невозможно скачать образ, раздел /data находится в режиме только для чтения',
|
||||
diskShared: 'Сначала отключите виртуальный диск, затем используйте загрузку образов.',
|
||||
diskOff: 'Отключить виртуальный диск',
|
||||
uploadbox: 'Перетащите сюда файл или нажмите, чтобы выбрать',
|
||||
inputfile: 'Пожалуйста, введите файл изображения',
|
||||
NoISO: 'Нет ISO',
|
||||
disabled:
|
||||
'Раздел данных доступен только для чтения, пока его использует целевой компьютер: новые образы записать нельзя, но существующие файлы по-прежнему можно скачать.',
|
||||
sha256: 'SHA-256 (необязательно)',
|
||||
sha256Placeholder: 'Введите контрольную сумму SHA-256 из 64 символов',
|
||||
invalidSHA256: 'SHA-256 должен быть шестнадцатеричной строкой из 64 символов',
|
||||
failed: 'Ошибка загрузки',
|
||||
success: 'Загрузка выполнена',
|
||||
checksumFailed: 'Ошибка загрузки: проверка SHA-256 не пройдена',
|
||||
cancel: 'Отмена',
|
||||
cancelFailed: 'Не удалось отменить загрузку'
|
||||
},
|
||||
@@ -458,16 +535,18 @@ const ru = {
|
||||
'Прекратить эмуляцию виртуальных устройств, сохранив только базовый контроль HID.',
|
||||
disk: 'Виртуальный диск',
|
||||
diskDesc: 'Смонтировать виртуальный USB диск на удаленном хосте',
|
||||
diskLocked: 'Отключён, пока смонтирован образ (включение отменит монтирование)',
|
||||
diskCancelMount:
|
||||
'Включение виртуального диска отменит смонтированный образ и прервёт текущую загрузку или выгрузку образа. Продолжить?',
|
||||
diskBreakDownload: 'Включение виртуального диска прервёт загрузку образов. Продолжить?',
|
||||
network: 'Виртуальная сеть',
|
||||
networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте',
|
||||
reboot: 'Перезагрузка',
|
||||
rebootDesc: 'Вы уверены, что хотите перезагрузить NanoKVM?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
cancelBtn: 'Нет',
|
||||
diskLocked: 'Отключён, пока смонтирован образ (включение отменит монтирование)',
|
||||
diskCancelMount:
|
||||
'Включение виртуального диска отменит смонтированный образ и прервёт текущую загрузку или выгрузку образа. Продолжить?',
|
||||
diskBreakDownload: 'Включение виртуального диска прервёт загрузку образов. Продолжить?',
|
||||
attention: 'Внимание',
|
||||
updateFailed: 'Не удалось обновить виртуальное устройство'
|
||||
},
|
||||
network: {
|
||||
title: 'Сеть',
|
||||
@@ -606,7 +685,88 @@ const ru = {
|
||||
logoutBtn: 'Выйти',
|
||||
logoutDesc: 'Вы действительно хотите выйти?',
|
||||
okBtn: 'Да',
|
||||
cancelBtn: 'Нет'
|
||||
cancelBtn: 'Нет',
|
||||
role: 'Роль',
|
||||
roles: {
|
||||
admin: 'Администратор',
|
||||
user: 'Пользователь'
|
||||
},
|
||||
users: {
|
||||
title: 'Пользователи',
|
||||
create: 'Создать пользователя',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Отключён',
|
||||
deviceOwner: 'Владелец устройства',
|
||||
resetPassword: 'Сбросить пароль',
|
||||
delete: 'Удалить',
|
||||
deleteConfirm: 'Удалить этого пользователя и отозвать все его сеансы?',
|
||||
created: 'Пользователь создан',
|
||||
deleted: 'Пользователь удалён',
|
||||
passwordUpdated: 'Пароль обновлён',
|
||||
loadFailed: 'Не удалось загрузить пользователей',
|
||||
saveFailed: 'Не удалось сохранить пользователя',
|
||||
deleteFailed: 'Не удалось удалить пользователя',
|
||||
editPermissions: 'Разрешения',
|
||||
mcpLossTitle: 'Отозвать доступ к MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} потеряет доступ к MCP. Ключ API MCP общий для всего устройства, поэтому его копия работает, пока ключ не создан заново. Пересоздание отключит все клиенты MCP, пока они не получат новый ключ.',
|
||||
mcpLossRotate: 'Создать новый ключ API MCP',
|
||||
mcpLossKeep:
|
||||
'Текущий ключ останется действительным, и владелец копии по-прежнему сможет управлять клавиатурой и мышью. Позже его можно пересоздать в настройках MCP.',
|
||||
mcpKeyRotated: 'Ключ API MCP создан заново. Клиентам MCP нужен новый ключ.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Разрешения',
|
||||
editTitle: 'Изменить разрешения',
|
||||
save: 'Сохранить',
|
||||
saved: 'Сохранено',
|
||||
close: 'Закрыть',
|
||||
groupConsole: 'Консоль',
|
||||
groupTarget: 'Целевая машина',
|
||||
groupLibrary: 'Библиотека носителей',
|
||||
groupHost: 'Это устройство',
|
||||
groupServices: 'Службы',
|
||||
consoleView: 'Просмотр экрана',
|
||||
consoleControl: 'Клавиатура и мышь',
|
||||
power: 'Питание и пробуждение',
|
||||
mediumAttach: 'Подключить носитель',
|
||||
libraryRead: 'Просмотр библиотеки',
|
||||
libraryWrite: 'Управление библиотекой',
|
||||
terminal: 'Терминал и скрипты',
|
||||
device: 'Настройки устройства',
|
||||
network: 'Настройки сети',
|
||||
update: 'Обновление системы',
|
||||
mcp: 'Служба MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'ИИ-ассистент',
|
||||
effectConsoleView:
|
||||
'Показывает экран целевой машины и задаёт режим видео, разрешение, частоту кадров и качество, общие для всех зрителей.',
|
||||
effectConsoleControl: 'Передаёт ввод с клавиатуры и мыши на целевую машину.',
|
||||
effectPower:
|
||||
'Включает, выключает и перезагружает целевую машину, а также управляет списком устройств Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Подключает и отключает носители на целевой машине и обменивается с ней файлами через общий раздел.',
|
||||
effectLibraryRead: 'Показывает и скачивает образы и файлы внутри них.',
|
||||
effectLibraryWrite:
|
||||
'Загружает, создаёт и удаляет образы, а также изменяет файлы внутри них.',
|
||||
effectTerminal: 'Даёт root-оболочку на этом устройстве, минуя веб-сеанс.',
|
||||
effectDevice:
|
||||
'Позволяет включить SSH (тоже root), перезагрузить устройство, передать целевой машине необработанный раздел данных и изменить настройки OLED, HDMI, mDNS, памяти и swap.',
|
||||
effectNetwork: 'Изменяет Wi-Fi, DNS и TLS, что может отключить это устройство от сети.',
|
||||
effectUpdate: 'Устанавливает прошивку и пакеты приложений.',
|
||||
effectMcp:
|
||||
'Выдаёт ключ API, действующий на всё устройство: он управляет клавиатурой, мышью и снимками экрана вне веб-сеанса; копия ключа работает и после отзыва разрешения, если ключ не создать заново — это предлагается при снятии разрешения.',
|
||||
effectTailscale: 'Подключает это устройство к сети VPN.',
|
||||
effectAi:
|
||||
'Управляет устройством через локальный интерфейс, который не ограничивается остальными разрешениями, поэтому это фактически полный контроль над устройством.',
|
||||
fullControlWarning: 'Эти разрешения означают полный контроль над устройством:',
|
||||
emptyWarning: 'У этой учётной записи не будет никаких функций, только вход.',
|
||||
impliedBy: 'Включено разрешением {{name}}',
|
||||
grantCount_one: 'Разрешений: {{count}}',
|
||||
grantCount: 'Разрешений: {{count}}',
|
||||
adminAll: 'Администраторы имеют все разрешения',
|
||||
catalogFailed: 'Не удалось загрузить список разрешений. Закройте окно и попробуйте снова.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const se = {
|
||||
login: 'Logga in',
|
||||
changePassword: 'Byt lösenord',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Avbildningar och fildelning'
|
||||
},
|
||||
auth: {
|
||||
login: 'Logga in',
|
||||
@@ -37,7 +38,10 @@ const se = {
|
||||
change2: 'Webbinloggningslösenord',
|
||||
change3: 'Systemets root-lösenord (SSH-lösenord)',
|
||||
change4: 'För att återställa lösenordet, håll in BOOT-knappen på NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Nuvarande lösenord',
|
||||
passwordLength: 'Lösenordet måste vara mellan 8 och 72 tecken',
|
||||
invalidCurrentPassword: 'Nuvarande lösenord är fel'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -56,6 +60,17 @@ const se = {
|
||||
verifyBtn: 'Verifiera'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Endast visning',
|
||||
readOnlyTip:
|
||||
'Det här kontot kan inte skicka tangentbords- eller musindata till målmaskinen, så konsolen visar bara skärmen.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Det här kontot har ingen konsolåtkomst',
|
||||
description:
|
||||
'Det har fått andra behörigheter. Öppna någon av sidorna det kan använda, eller be en administratör om konsolåtkomst.',
|
||||
nothing: 'Det här kontot har ännu inga behörigheter. Be en administratör att ge några.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Skala',
|
||||
title: 'Skärm',
|
||||
@@ -210,33 +225,44 @@ const se = {
|
||||
disable: 'Inaktivera Endast-HID-läge'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Avbildningar',
|
||||
loading: 'Laddar...',
|
||||
empty: 'Inget hittades',
|
||||
storage: {
|
||||
title: 'Avbildningar och fildelning',
|
||||
empty:
|
||||
'Inga avbildningar än. Ladda upp en ISO eller IMG, eller kopiera en till disken från måldatorn.',
|
||||
notAttached: 'Inget monterat',
|
||||
attached: 'Monterat: {{name}}',
|
||||
eject: 'Mata ut',
|
||||
mount: 'Montera',
|
||||
mountMode: 'Monteringsläge',
|
||||
mountFailed: 'Montering misslyckades',
|
||||
mountDesc:
|
||||
'I vissa system måste den virtuella disken avmonteras på fjärrvärden innan avbildningen monteras.',
|
||||
diskOnWarn:
|
||||
'Montering av avbildningen stänger av den virtuella disken, så datorn kommer inte åt /data. Fortsätta?',
|
||||
diskOffWarn:
|
||||
'Den virtuella disken är avstängd och måste aktiveras för att montera avbildningen. Datorns USB-enheter ansluts på nytt. Fortsätta?',
|
||||
diskShared:
|
||||
'Den virtuella disken är aktiverad, så /data är skrivskyddad här. Stäng av den för att ta bort eller ladda upp avbildningar.',
|
||||
unmountFailed: 'Avmontering misslyckades',
|
||||
unmountDesc:
|
||||
'I vissa system måste du manuellt mata ut från fjärrvärden innan du avmonterar avbildningen.',
|
||||
refresh: 'Uppdatera avbildningslistan',
|
||||
attention: 'Observera',
|
||||
deleteConfirm: 'Är du säker på att du vill ta bort denna avbildning?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nej',
|
||||
mountModeDisk: 'Massminne',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Öppna filhanteraren',
|
||||
refresh: 'Uppdatera',
|
||||
delete: 'Ta bort',
|
||||
deleteTitle: 'Ta bort medium',
|
||||
deleteConfirm: 'Ta bort {{name}}? Filerna i den går förlorade.',
|
||||
downloadImage: 'Hämta avbildningen',
|
||||
typePartition: 'Delade filer',
|
||||
volumeLabel: 'Flyttbar disk',
|
||||
groups: {
|
||||
share: 'Fildelning',
|
||||
images: 'Avbildningar'
|
||||
},
|
||||
failed: 'Åtgärden misslyckades',
|
||||
hidOnly: 'Endast HID-läge: den virtuella disken är inte tillgänglig',
|
||||
disabled: 'Den virtuella disken är avstängd (Inställningar - Enhet)',
|
||||
force: {
|
||||
title: 'Ta tillbaka mediet med tvång?',
|
||||
desc: 'Måldatorn använder fortfarande mediet. Att ta tillbaka det räknar upp USB-enheten på nytt och kan förlora allt den ännu inte har skrivit till disken.',
|
||||
menuHint:
|
||||
'Måldatorn använder mediet. Öppna filhanteraren för att ta tillbaka det med tvång.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Hur man laddar upp',
|
||||
usb1: 'Anslut NanoKVM till din dator via USB.',
|
||||
usb2: 'Säkerställ att den virtuella disken är monterad (Inställningar - Virtuell Disk).',
|
||||
usb2: 'Montera de delade filerna i masslagringsläge på den här sidan.',
|
||||
usb3: 'Öppna den virtuella disken på din dator och kopiera avbildningsfilen till rotkatalogen.',
|
||||
usb4: 'Mata ut disken när kopieringen är klar: avbilden visas då i listan.',
|
||||
scp1: 'Säkerställ att NanoKVM och din dator är på samma lokala nätverk.',
|
||||
scp2: 'Öppna en terminal på din dator och använd SCP-kommandot för att ladda upp avbildningen till /data på NanoKVM.',
|
||||
scp3: 'Exempel: scp din-avbildningssökväg root@din-nanokvm-ip:/data',
|
||||
@@ -246,7 +272,68 @@ const se = {
|
||||
tf3: 'Sätt in TF-kortet i en kortläsare och anslut till din dator.',
|
||||
tf4: 'Kopiera avbildningsfilen till /data på TF-kortet.',
|
||||
tf5: 'Sätt in TF-kortet i NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Arbetar med {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Aktivera den virtuella disken?',
|
||||
desc: 'För att montera ett medium krävs den virtuella disken, som är avstängd just nu. Aktivera den? Måldatorns tangentbord och mus kopplas från ett ögonblick.',
|
||||
menuHint: 'Den virtuella disken är avstängd. Aktivera den under Inställningar - Enhet.',
|
||||
askDevice:
|
||||
'Den virtuella disken är avstängd. Ett konto med enhetsbehörighet måste aktivera den först.'
|
||||
},
|
||||
add: {
|
||||
title: 'Lägg till medium',
|
||||
upload: 'Ladda upp en ISO eller IMG',
|
||||
fromUrl: 'Hämta från en URL',
|
||||
url: 'URL till avbildningen',
|
||||
urlRequired: 'Ange en URL',
|
||||
onlyImages: 'Bara .iso- och .img-filer kan monteras',
|
||||
dropHint: 'Dra en ISO eller IMG hit, eller klicka för att välja en fil',
|
||||
cdromOnlyIso: 'En CD-ROM kan bara innehålla en ISO 9660-avbildning',
|
||||
uploadFailed: 'Uppladdningen misslyckades',
|
||||
downloadFailed: 'Nedladdningen misslyckades',
|
||||
uploading: 'Laddar upp {{name}}',
|
||||
downloading: 'Laddar ner {{name}}',
|
||||
done: '{{name}} är klar',
|
||||
checksumFailed: 'Avbilden matchar inte SHA-256-kontrollsumman.',
|
||||
blocked: 'Måldatorn använder disken',
|
||||
copyHint:
|
||||
'Du kan också kopiera en avbildning till datapartitionen via SCP, eller från måldatorn.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Välj ett medium för att visa filerna på det.',
|
||||
reasonAttached: 'Mediet är kopplat till måldatorn.',
|
||||
reasonAttachedHint:
|
||||
'Mata ut det först: måldatorn måste släppa disken innan filerna kan läsas.',
|
||||
reasonNotBrowsable: 'Mediet innehåller ett filsystem som filhanteraren inte kan öppna.',
|
||||
reasonNoLoop: 'Enheten kan inte montera diskavbildningar (inget loop-stöd).',
|
||||
reasonISOImage:
|
||||
'En ISO 9660-avbildning kan inte bläddras här. Den kan fortfarande monteras på måldatorn.',
|
||||
empty: 'Mappen är tom',
|
||||
noMatch: 'Inga filer matchar sökningen.',
|
||||
free: '{{size}} ledigt',
|
||||
search: 'Sök i mappen',
|
||||
sort: {
|
||||
name: 'Namn',
|
||||
size: 'Storlek',
|
||||
type: 'Typ',
|
||||
asc: 'Stigande',
|
||||
desc: 'Fallande'
|
||||
},
|
||||
upload: 'Ladda upp en fil till mediet',
|
||||
deleteFolderTitle: 'Ta bort mapp',
|
||||
deleteFolderConfirm: 'Ta bort mappen {{name}} och allt innehåll? Detta kan inte ångras.',
|
||||
uploading: 'Laddar upp…',
|
||||
uploadFailed: 'Uppladdningen misslyckades',
|
||||
download: 'Hämta',
|
||||
delete: 'Ta bort',
|
||||
deleteTitle: 'Ta bort fil',
|
||||
deleteConfirm: 'Ta bort {{name}} från mediet?'
|
||||
},
|
||||
yes: 'Ja',
|
||||
no: 'Nej'
|
||||
},
|
||||
script: {
|
||||
title: 'Skript',
|
||||
@@ -288,21 +375,11 @@ const se = {
|
||||
ok: 'Ok'
|
||||
},
|
||||
download: {
|
||||
title: 'Avbildningshämtare',
|
||||
input: 'Ange en fjärravbildnings-URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partitionen är skrivskyddad, kan inte hämta avbildning',
|
||||
diskShared: 'Stäng av den virtuella disken först och använd sedan nedladdningen.',
|
||||
diskOff: 'Stäng av den virtuella disken',
|
||||
uploadbox: 'Släpp filen här eller klicka för att välja',
|
||||
inputfile: 'Vänligen ange bildfilen',
|
||||
NoISO: 'Ingen ISO',
|
||||
disabled:
|
||||
'Datapartitionen är skrivskyddad medan måldatorn använder den: nya avbildningar kan inte skrivas, men befintliga filer går fortfarande att hämta.',
|
||||
sha256: 'SHA-256 (valfrie)',
|
||||
sha256Placeholder: 'Skriv inn en SHA-256-kontrollsum på 64 tegn',
|
||||
invalidSHA256: 'SHA-256 må være en heksadesimal streng på 64 tegn',
|
||||
failed: 'Nedlasting mislyktes',
|
||||
success: 'Nedlasting fullført',
|
||||
checksumFailed: 'Nedlasting mislyktes: SHA-256-verifisering mislyktes',
|
||||
cancel: 'Avbryt',
|
||||
cancelFailed: 'Kunne ikke avbryte nedlastingen'
|
||||
},
|
||||
@@ -452,17 +529,19 @@ const se = {
|
||||
hidOnlyDesc: 'Sluta emulera virtuella enheter, behåll bara grundläggande HID kontroll',
|
||||
disk: 'Virtuell disk',
|
||||
diskDesc: 'Montera virtuell U-disk på fjärrvärden',
|
||||
diskLocked: 'Avstängd medan en avbildning är monterad (aktivering avbryter monteringen)',
|
||||
diskCancelMount:
|
||||
'Att aktivera den virtuella disken avbryter den monterade avbildningen och avbryter en pågående nedladdning eller uppladdning. Fortsätta?',
|
||||
diskBreakDownload:
|
||||
'Att aktivera den virtuella disken avbryter nedladdningen av avbildningar. Fortsätta?',
|
||||
network: 'Virtuellt nätverk',
|
||||
networkDesc: 'Montera virtuell nätverkskort på fjärrvärden',
|
||||
reboot: 'Starta om',
|
||||
rebootDesc: 'Är du säker på att du vill starta om NanoKVM?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nej'
|
||||
cancelBtn: 'Nej',
|
||||
diskLocked: 'Avstängd medan en avbildning är monterad (aktivering avbryter monteringen)',
|
||||
diskCancelMount:
|
||||
'Att aktivera den virtuella disken avbryter den monterade avbildningen och avbryter en pågående nedladdning eller uppladdning. Fortsätta?',
|
||||
diskBreakDownload:
|
||||
'Att aktivera den virtuella disken avbryter nedladdningen av avbildningar. Fortsätta?',
|
||||
attention: 'Observera',
|
||||
updateFailed: 'Det gick inte att uppdatera den virtuella enheten'
|
||||
},
|
||||
network: {
|
||||
title: 'Nätverk',
|
||||
@@ -600,7 +679,89 @@ const se = {
|
||||
logoutBtn: 'Logga ut',
|
||||
logoutDesc: 'Är du säker på att du vill logga ut?',
|
||||
okBtn: 'Ja',
|
||||
cancelBtn: 'Nej'
|
||||
cancelBtn: 'Nej',
|
||||
role: 'Roll',
|
||||
roles: {
|
||||
admin: 'Administratör',
|
||||
user: 'Användare'
|
||||
},
|
||||
users: {
|
||||
title: 'Användare',
|
||||
create: 'Skapa användare',
|
||||
enabled: 'Aktiverad',
|
||||
disabled: 'Inaktiverad',
|
||||
deviceOwner: 'Enhetens ägare',
|
||||
resetPassword: 'Återställ lösenord',
|
||||
delete: 'Ta bort',
|
||||
deleteConfirm: 'Ta bort användaren och återkalla alla dess sessioner?',
|
||||
created: 'Användare skapad',
|
||||
deleted: 'Användare borttagen',
|
||||
passwordUpdated: 'Lösenordet uppdaterat',
|
||||
loadFailed: 'Kunde inte läsa in användare',
|
||||
saveFailed: 'Kunde inte spara användaren',
|
||||
deleteFailed: 'Kunde inte ta bort användaren',
|
||||
editPermissions: 'Behörigheter',
|
||||
mcpLossTitle: 'Ta bort MCP-åtkomst',
|
||||
mcpLossDesc:
|
||||
'{{name}} förlorar MCP-åtkomst. MCP-API-nyckeln delas av hela enheten, så en kopia fungerar tills nyckeln skapas på nytt. Att skapa den på nytt kopplar från alla MCP-klienter tills de får den nya nyckeln.',
|
||||
mcpLossRotate: 'Skapa ny MCP-API-nyckel',
|
||||
mcpLossKeep:
|
||||
'Den nuvarande nyckeln förblir giltig, och den som har en kopia kan fortfarande styra tangentbord och mus. Du kan skapa en ny senare i MCP-inställningarna.',
|
||||
mcpKeyRotated: 'Ny MCP-API-nyckel skapad. MCP-klienter behöver den nya nyckeln.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Behörigheter',
|
||||
editTitle: 'Redigera behörigheter',
|
||||
save: 'Spara',
|
||||
saved: 'Sparat',
|
||||
close: 'Stäng',
|
||||
groupConsole: 'Konsol',
|
||||
groupTarget: 'Målmaskin',
|
||||
groupLibrary: 'Mediabibliotek',
|
||||
groupHost: 'Den här enheten',
|
||||
groupServices: 'Tjänster',
|
||||
consoleView: 'Se skärmen',
|
||||
consoleControl: 'Tangentbord och mus',
|
||||
power: 'Ström och väckning',
|
||||
mediumAttach: 'Anslut en media',
|
||||
libraryRead: 'Bläddra i biblioteket',
|
||||
libraryWrite: 'Hantera biblioteket',
|
||||
terminal: 'Terminal och skript',
|
||||
device: 'Enhetsinställningar',
|
||||
network: 'Nätverksinställningar',
|
||||
update: 'Systemuppdatering',
|
||||
mcp: 'MCP-tjänst',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI-assistent',
|
||||
effectConsoleView:
|
||||
'Visar målmaskinens skärm och anger videoläge, upplösning, bildfrekvens och kvalitet som alla tittare delar.',
|
||||
effectConsoleControl: 'Skickar tangentbords- och musinmatning till målmaskinen.',
|
||||
effectPower:
|
||||
'Slår på, stänger av och startar om målmaskinen samt hanterar listan med Wake-on-LAN-enheter.',
|
||||
effectMediumAttach:
|
||||
'Monterar och matar ut media på målmaskinen och utbyter filer med den via den delade partitionen.',
|
||||
effectLibraryRead: 'Listar och hämtar avbildningarna och filerna i dem.',
|
||||
effectLibraryWrite:
|
||||
'Laddar upp, skapar och tar bort avbildningar och ändrar filerna i dem.',
|
||||
effectTerminal: 'Ger ett root-skal på den här enheten, utan webbsessionen emellan.',
|
||||
effectDevice:
|
||||
'Gör det möjligt att slå på SSH (också root), starta om enheten, lämna den råa datapartitionen till målmaskinen och ändra OLED, HDMI, mDNS, minne och swap.',
|
||||
effectNetwork:
|
||||
'Ändrar Wi-Fi, DNS och TLS, vilket kan koppla bort den här enheten från nätet.',
|
||||
effectUpdate: 'Installerar firmware och programpaket.',
|
||||
effectMcp:
|
||||
'Utfärdar en API-nyckel för hela enheten som styr tangentbord, mus och skärmbilder utanför webbsessionen; en kopia fungerar även efter att behörigheten tagits bort om inte nyckeln skapas på nytt, vilket erbjuds när behörigheten tas bort.',
|
||||
effectTailscale: 'Ansluter den här enheten till ett VPN-nät.',
|
||||
effectAi:
|
||||
'Styr enheten via ett lokalt gränssnitt som de övriga behörigheterna inte begränsar, så det motsvarar full kontroll över enheten.',
|
||||
fullControlWarning: 'Dessa behörigheter motsvarar full kontroll över enheten:',
|
||||
emptyWarning: 'Det här kontot får inga funktioner alls och kan bara logga in.',
|
||||
impliedBy: 'Ingår via {{name}}',
|
||||
grantCount_one: '{{count}} behörighet',
|
||||
grantCount: '{{count}} behörigheter',
|
||||
adminAll: 'Administratörer har alla behörigheter',
|
||||
catalogFailed: 'Behörighetslistan kunde inte läsas in. Stäng dialogen och försök igen.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const th = {
|
||||
login: 'เข้าสู่ระบบ',
|
||||
changePassword: 'เปลี่ยนรหัสผ่าน',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'อิมเมจและการแชร์ไฟล์'
|
||||
},
|
||||
auth: {
|
||||
login: 'เข้าสู่ระบบ',
|
||||
@@ -37,7 +38,10 @@ const th = {
|
||||
change2: 'รหัสผ่านเว็บไชต์',
|
||||
change3: 'รหัส Root ของระบบ (ใช้เข้า SSH)',
|
||||
change4: 'เพื่อรีเช็ทรหัสผ่าน, กรุณากดปุ่ม BOOT บนอุปกรณ์ NanoKVM ค้างไว้.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'รหัสผ่านปัจจุบัน',
|
||||
passwordLength: 'รหัสผ่านต้องมีความยาว 8 ถึง 72 ตัวอักษร',
|
||||
invalidCurrentPassword: 'รหัสผ่านปัจจุบันไม่ถูกต้อง'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -56,6 +60,17 @@ const th = {
|
||||
verifyBtn: 'ตรวจสอบ'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'ดูอย่างเดียว',
|
||||
readOnlyTip:
|
||||
'บัญชีนี้ไม่สามารถส่งข้อมูลคีย์บอร์ดหรือเมาส์ไปยังเครื่องเป้าหมายได้ คอนโซลจึงแสดงเฉพาะหน้าจอ'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'บัญชีนี้ไม่มีสิทธิ์เข้าถึงคอนโซล',
|
||||
description:
|
||||
'บัญชีนี้ได้รับความสามารถอื่น เปิดหน้าที่ใช้งานได้ด้านล่าง หรือขอให้ผู้ดูแลระบบให้สิทธิ์คอนโซล',
|
||||
nothing: 'บัญชีนี้ยังไม่มีสิทธิ์ใด ๆ ขอให้ผู้ดูแลระบบเพิ่มสิทธิ์'
|
||||
},
|
||||
screen: {
|
||||
scale: 'สเกล',
|
||||
title: 'หน้าจอ',
|
||||
@@ -208,32 +223,42 @@ const th = {
|
||||
disable: 'ปิดใช้งานโหมด HID-Only'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'ดิสก์จำลอง',
|
||||
loading: 'กำลังโหลด...',
|
||||
empty: 'ไม่พบดิสก์',
|
||||
storage: {
|
||||
title: 'อิมเมจและการแชร์ไฟล์',
|
||||
empty: 'ยังไม่มีอิมเมจ อัปโหลด ISO หรือ IMG หรือคัดลอกไฟล์ลงดิสก์จากเครื่องเป้าหมาย',
|
||||
notAttached: 'ยังไม่ได้เมาท์สิ่งใด',
|
||||
attached: 'เมาท์อยู่: {{name}}',
|
||||
eject: 'ดีดออก',
|
||||
mount: 'เมาท์',
|
||||
mountMode: 'โหมดเมาท์',
|
||||
mountFailed: 'การติดตั้งล้มเหลว',
|
||||
mountDesc: 'ในบางระบบ, จะต้อง eject ดิสก์จำลองก่อนที่จะใส่ดิสก์ใหม่.',
|
||||
diskOnWarn:
|
||||
'การเมานต์อิมเมจจะปิดดิสก์เสมือน ทำให้คอมพิวเตอร์เข้าถึง /data ไม่ได้อีกต่อไป ดำเนินการต่อหรือไม่?',
|
||||
diskOffWarn:
|
||||
'ดิสก์เสมือนปิดอยู่ ต้องเปิดก่อนจึงจะเมานต์อิมเมจได้ อุปกรณ์ USB ของคอมพิวเตอร์จะเชื่อมต่อใหม่ ดำเนินการต่อหรือไม่?',
|
||||
diskShared:
|
||||
'ดิสก์เสมือนเปิดอยู่ ดังนั้น /data จึงเป็นแบบอ่านอย่างเดียว ปิดดิสก์เสมือนเพื่อลบหรืออัปโหลดอิมเมจ',
|
||||
unmountFailed: 'การถอนติดตั้งล้มเหลว',
|
||||
unmountDesc:
|
||||
'ในบางระบบ คุณต้องดีดออกจากโฮสต์ระยะไกลด้วยตนเองก่อนที่จะยกเลิกการต่อเชื่อมอิมเมจ',
|
||||
refresh: 'รีเฟรชรายการรูปภาพ',
|
||||
attention: 'ความสนใจ',
|
||||
deleteConfirm: 'คุณแน่ใจหรือไม่ว่าต้องการลบภาพนี้?',
|
||||
okBtn: 'ใช่',
|
||||
cancelBtn: 'ไม่ใช่',
|
||||
mountModeDisk: 'ที่เก็บข้อมูลจำนวนมาก',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'เปิดตัวจัดการไฟล์',
|
||||
refresh: 'รีเฟรช',
|
||||
delete: 'ลบ',
|
||||
deleteTitle: 'ลบสื่อ',
|
||||
deleteConfirm: 'ลบ {{name}} หรือไม่? ไฟล์ที่อยู่ข้างในจะหายไป',
|
||||
downloadImage: 'ดาวน์โหลดอิมเมจ',
|
||||
typePartition: 'ไฟล์ที่แชร์',
|
||||
volumeLabel: 'ดิสก์แบบถอดได้',
|
||||
groups: {
|
||||
share: 'การแชร์ไฟล์',
|
||||
images: 'อิมเมจ'
|
||||
},
|
||||
failed: 'การทำงานล้มเหลว',
|
||||
hidOnly: 'โหมด HID เท่านั้น: ดิสก์เสมือนใช้งานไม่ได้',
|
||||
disabled: 'ดิสก์เสมือนถูกปิดอยู่ (ตั้งค่า - อุปกรณ์)',
|
||||
force: {
|
||||
title: 'บังคับดึงสื่อกลับหรือไม่?',
|
||||
desc: 'เครื่องเป้าหมายยังใช้งานสื่อนี้อยู่ การดึงกลับจะทำให้อุปกรณ์ USB ถูกระบุใหม่ และข้อมูลที่ยังไม่ได้เขียนลงดิสก์อาจสูญหาย',
|
||||
menuHint: 'เครื่องเป้าหมายกำลังใช้สื่อนี้ เปิดตัวจัดการไฟล์เพื่อบังคับดึงกลับ'
|
||||
},
|
||||
tips: {
|
||||
title: 'วิธีการอัปโหลด',
|
||||
usb1: 'เชื่อมต่อ NanoKVM กับคอมพิวเตอร์ของคุณผ่าน USB',
|
||||
usb2: 'ตรวจสอบให้แน่ใจว่าได้เปิดใช้งานดิสก์เสมือนแล้ว (ตั้งค่า - ดิสก์เสมือน)',
|
||||
usb2: 'ในหน้านี้ ให้เมานต์ไฟล์ที่แชร์ในโหมดอุปกรณ์จัดเก็บข้อมูลขนาดใหญ่',
|
||||
usb3: 'เปิดดิสก์บนคอมพิวเตอร์ของคุณและคัดลอกไฟล์อิมเมจไปยังไดเรกทอรีหลักของดิสก์',
|
||||
usb4: 'เมื่อคัดลอกเสร็จแล้วให้นำดิสก์ออก จากนั้นอิมเมจจะปรากฏในรายการ',
|
||||
scp1: 'ตรวจสอบให้แน่ใจว่า NanoKVM และคอมพิวเตอร์ของคุณอยู่ในเครือข่ายเดียวกัน',
|
||||
scp2: 'เปิดเทอร์มินัลบนคอมพิวเตอร์ของคุณและใช้คำสั่ง SCP เพื่ออัปโหลดไฟล์อิมเมจไปยังไดเรกทอรี /data บน NanoKVM',
|
||||
scp3: 'ตัวอย่าง: scp <ที่อยู่ของไฟล์> root@<ip อุปกรณ์>:/data',
|
||||
@@ -243,7 +268,66 @@ const th = {
|
||||
tf3: 'ใส่การ์ด TF ลงในเครื่องอ่านการ์ดและเชื่อมต่อกับคอมพิวเตอร์ของคุณ',
|
||||
tf4: 'คัดลอกไฟล์อิมเมจไปยังไดเรกทอรี /data บนการ์ด TF',
|
||||
tf5: 'ใส่การ์ด TF กลับเข้า NanoKVM'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'กำลังประมวลผล {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'เปิดดิสก์เสมือนหรือไม่?',
|
||||
desc: 'การเมาท์สื่อต้องใช้ดิสก์เสมือน ซึ่งขณะนี้ปิดอยู่ เปิดเลยหรือไม่? คีย์บอร์ดและเมาส์ของเครื่องเป้าหมายจะขาดการเชื่อมต่อชั่วครู่',
|
||||
menuHint: 'ดิสก์เสมือนถูกปิดอยู่ เปิดได้ที่ ตั้งค่า - อุปกรณ์',
|
||||
askDevice: 'ดิสก์เสมือนถูกปิดอยู่ บัญชีที่มีสิทธิ์อุปกรณ์ต้องเปิดใช้งานก่อน'
|
||||
},
|
||||
add: {
|
||||
title: 'เพิ่มสื่อ',
|
||||
upload: 'อัปโหลด ISO หรือ IMG',
|
||||
fromUrl: 'ดาวน์โหลดจาก URL',
|
||||
url: 'URL ของอิมเมจ',
|
||||
urlRequired: 'กรุณากรอก URL',
|
||||
onlyImages: 'เมาท์ได้เฉพาะไฟล์ .iso และ .img',
|
||||
dropHint: 'ลาก ISO หรือ IMG มาที่นี่ หรือคลิกเพื่อเลือกไฟล์',
|
||||
cdromOnlyIso: 'CD-ROM ใส่ได้เฉพาะอิมเมจ ISO 9660 เท่านั้น',
|
||||
uploadFailed: 'อัปโหลดล้มเหลว',
|
||||
downloadFailed: 'ดาวน์โหลดล้มเหลว',
|
||||
uploading: 'กำลังอัปโหลด {{name}}',
|
||||
downloading: 'กำลังดาวน์โหลด {{name}}',
|
||||
done: '{{name}} พร้อมใช้งาน',
|
||||
checksumFailed: 'อิมเมจไม่ตรงกับ checksum SHA-256',
|
||||
blocked: 'เครื่องเป้าหมายกำลังใช้ดิสก์นี้',
|
||||
copyHint: 'คุณสามารถคัดลอกอิมเมจไปยังพาร์ติชันข้อมูลผ่าน SCP หรือจากเครื่องเป้าหมายก็ได้'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'เลือกสื่อเพื่อดูไฟล์ที่อยู่ข้างใน',
|
||||
reasonAttached: 'สื่อนี้เชื่อมต่อกับเครื่องเป้าหมายอยู่',
|
||||
reasonAttachedHint: 'กรุณาดีดออกก่อน: เครื่องเป้าหมายต้องปล่อยดิสก์ก่อนจึงจะอ่านไฟล์ได้',
|
||||
reasonNotBrowsable: 'สื่อนี้มีระบบไฟล์ที่ตัวจัดการไฟล์เปิดไม่ได้',
|
||||
reasonNoLoop: 'อุปกรณ์นี้เมาท์อิมเมจดิสก์ไม่ได้ (ไม่รองรับ loop)',
|
||||
reasonISOImage:
|
||||
'ยังเรียกดูเนื้อหาในอิมเมจ ISO 9660 จากที่นี่ไม่ได้ แต่ยังเมาท์ให้เครื่องเป้าหมายได้ตามปกติ',
|
||||
empty: 'โฟลเดอร์นี้ว่างเปล่า',
|
||||
noMatch: 'ไม่มีไฟล์ที่ตรงกับการค้นหา',
|
||||
free: 'ว่าง {{size}}',
|
||||
search: 'ค้นหาในโฟลเดอร์นี้',
|
||||
sort: {
|
||||
name: 'ชื่อ',
|
||||
size: 'ขนาด',
|
||||
type: 'ชนิด',
|
||||
asc: 'น้อยไปมาก',
|
||||
desc: 'มากไปน้อย'
|
||||
},
|
||||
upload: 'อัปโหลดไฟล์ลงสื่อ',
|
||||
deleteFolderTitle: 'ลบโฟลเดอร์',
|
||||
deleteFolderConfirm:
|
||||
'ลบโฟลเดอร์ {{name}} และทุกอย่างภายในหรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับได้',
|
||||
uploading: 'กำลังอัปโหลด…',
|
||||
uploadFailed: 'อัปโหลดล้มเหลว',
|
||||
download: 'ดาวน์โหลด',
|
||||
delete: 'ลบ',
|
||||
deleteTitle: 'ลบไฟล์',
|
||||
deleteConfirm: 'ลบ {{name}} ออกจากสื่อหรือไม่?'
|
||||
},
|
||||
yes: 'ใช่',
|
||||
no: 'ไม่ใช่'
|
||||
},
|
||||
script: {
|
||||
title: 'สคริปต์',
|
||||
@@ -285,21 +369,11 @@ const th = {
|
||||
ok: 'ยืนยัน'
|
||||
},
|
||||
download: {
|
||||
title: 'ดาวโหลด Disk Image',
|
||||
input: 'กรุณาใส่ URL ของอิมเมจระยะไกล',
|
||||
ok: 'ยืนยัน',
|
||||
disabled: '/data มีการตั้งค่าเป็นอ่านอย่างเดียว ดังนั้นเราไม่สามารถดาวโหลด Disk Image ได้',
|
||||
diskShared: 'ปิดดิสก์เสมือนก่อน แล้วจึงใช้การดาวน์โหลดอิมเมจ',
|
||||
diskOff: 'ปิดดิสก์เสมือน',
|
||||
uploadbox: 'วางไฟล์ที่นี่หรือคลิกเพื่อเลือก',
|
||||
inputfile: 'กรุณากรอกไฟล์ภาพ',
|
||||
NoISO: 'ไม่มี ISO',
|
||||
disabled:
|
||||
'พาร์ติชันข้อมูลเป็นแบบอ่านอย่างเดียวขณะที่เครื่องเป้าหมายใช้งาน: เขียนอิมเมจใหม่ไม่ได้ แต่ยังดาวน์โหลดไฟล์ที่มีอยู่ได้ตามปกติ',
|
||||
sha256: 'SHA-256 (ไม่บังคับ)',
|
||||
sha256Placeholder: 'ป้อน checksum SHA-256 64 ตัวอักษร',
|
||||
invalidSHA256: 'SHA-256 ต้องเป็นสตริงเลขฐานสิบหก 64 ตัวอักษร',
|
||||
failed: 'ดาวน์โหลดล้มเหลว',
|
||||
success: 'ดาวน์โหลดสำเร็จ',
|
||||
checksumFailed: 'ดาวน์โหลดล้มเหลว: การตรวจสอบ SHA-256 ล้มเหลว',
|
||||
cancel: 'ยกเลิก',
|
||||
cancelFailed: 'ยกเลิกการดาวน์โหลดไม่สำเร็จ'
|
||||
},
|
||||
@@ -447,16 +521,18 @@ const th = {
|
||||
hidOnlyDesc: 'หยุดการจำลองอุปกรณ์เสมือน โดยคงไว้เพียงการควบคุม HID พื้นฐานเท่านั้น',
|
||||
disk: 'ดิสก์จำลอง',
|
||||
diskDesc: 'เปิดใช้งาน U-disk จำลอง',
|
||||
diskLocked: 'ปิดอยู่ขณะเมานต์อิมเมจ (เปิดจะยกเลิกการเมานต์)',
|
||||
diskCancelMount:
|
||||
'การเปิดดิสก์เสมือนจะยกเลิกการเมานต์อิมเมจ และขัดขวางการดาวน์โหลดหรืออัปโหลดอิมเมจที่กำลังดำเนินอยู่ ดำเนินการต่อหรือไม่?',
|
||||
diskBreakDownload: 'การเปิดดิสก์เสมือนจะขัดขวางการดาวน์โหลดอิมเมจ ดำเนินการต่อหรือไม่?',
|
||||
network: 'เครือข่ายจำลอง',
|
||||
networkDesc: 'เปิดใช้งานอุปกรณ์เครือข่ายจำลอง',
|
||||
reboot: 'รีบูต',
|
||||
rebootDesc: 'คุณแน่ใจหรือไม่ว่าต้องการรีบูต NanoKVM',
|
||||
okBtn: 'ใช่',
|
||||
cancelBtn: 'ไม่ใช่'
|
||||
cancelBtn: 'ไม่ใช่',
|
||||
diskLocked: 'ปิดอยู่ขณะเมานต์อิมเมจ (เปิดจะยกเลิกการเมานต์)',
|
||||
diskCancelMount:
|
||||
'การเปิดดิสก์เสมือนจะยกเลิกการเมานต์อิมเมจ และขัดขวางการดาวน์โหลดหรืออัปโหลดอิมเมจที่กำลังดำเนินอยู่ ดำเนินการต่อหรือไม่?',
|
||||
diskBreakDownload: 'การเปิดดิสก์เสมือนจะขัดขวางการดาวน์โหลดอิมเมจ ดำเนินการต่อหรือไม่?',
|
||||
attention: 'ความสนใจ',
|
||||
updateFailed: 'อัปเดตอุปกรณ์เสมือนไม่สำเร็จ'
|
||||
},
|
||||
network: {
|
||||
title: 'เครือข่าย',
|
||||
@@ -594,7 +670,86 @@ const th = {
|
||||
logoutBtn: 'ออกจากระบบ',
|
||||
logoutDesc: 'คุณแน่ใจที่จะออกจากระบบหรือไม่?',
|
||||
okBtn: 'ใช่',
|
||||
cancelBtn: 'ไม่ใช่'
|
||||
cancelBtn: 'ไม่ใช่',
|
||||
role: 'บทบาท',
|
||||
roles: {
|
||||
admin: 'ผู้ดูแลระบบ',
|
||||
user: 'ผู้ใช้'
|
||||
},
|
||||
users: {
|
||||
title: 'ผู้ใช้',
|
||||
create: 'สร้างผู้ใช้',
|
||||
enabled: 'เปิดใช้งาน',
|
||||
disabled: 'ปิดใช้งาน',
|
||||
deviceOwner: 'เจ้าของอุปกรณ์',
|
||||
resetPassword: 'รีเซ็ตรหัสผ่าน',
|
||||
delete: 'ลบ',
|
||||
deleteConfirm: 'ลบผู้ใช้รายนี้และเพิกถอนเซสชันทั้งหมดหรือไม่?',
|
||||
created: 'สร้างผู้ใช้แล้ว',
|
||||
deleted: 'ลบผู้ใช้แล้ว',
|
||||
passwordUpdated: 'อัปเดตรหัสผ่านแล้ว',
|
||||
loadFailed: 'โหลดผู้ใช้ไม่สำเร็จ',
|
||||
saveFailed: 'บันทึกผู้ใช้ไม่สำเร็จ',
|
||||
deleteFailed: 'ลบผู้ใช้ไม่สำเร็จ',
|
||||
editPermissions: 'สิทธิ์',
|
||||
mcpLossTitle: 'เพิกถอนการเข้าถึง MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} จะเสียสิทธิ์เข้าถึง MCP คีย์ API ของ MCP ใช้ร่วมกันทั้งอุปกรณ์ สำเนาของคีย์จึงยังใช้ได้จนกว่าจะสร้างคีย์ใหม่ การสร้างใหม่จะตัดการเชื่อมต่อไคลเอนต์ MCP ทั้งหมดจนกว่าจะได้รับคีย์ใหม่',
|
||||
mcpLossRotate: 'สร้างคีย์ API ของ MCP ใหม่',
|
||||
mcpLossKeep:
|
||||
'คีย์ปัจจุบันยังใช้ได้ และผู้ที่มีสำเนายังควบคุมคีย์บอร์ดและเมาส์ได้ คุณสามารถสร้างใหม่ภายหลังได้ในการตั้งค่า MCP',
|
||||
mcpKeyRotated: 'สร้างคีย์ API ของ MCP ใหม่แล้ว ไคลเอนต์ MCP ต้องใช้คีย์ใหม่'
|
||||
},
|
||||
permissions: {
|
||||
title: 'สิทธิ์',
|
||||
editTitle: 'แก้ไขสิทธิ์',
|
||||
save: 'บันทึก',
|
||||
saved: 'บันทึกแล้ว',
|
||||
close: 'ปิด',
|
||||
groupConsole: 'คอนโซล',
|
||||
groupTarget: 'เครื่องเป้าหมาย',
|
||||
groupLibrary: 'คลังสื่อ',
|
||||
groupHost: 'อุปกรณ์นี้',
|
||||
groupServices: 'บริการ',
|
||||
consoleView: 'ดูหน้าจอ',
|
||||
consoleControl: 'คีย์บอร์ดและเมาส์',
|
||||
power: 'พลังงานและการปลุก',
|
||||
mediumAttach: 'เมาท์สื่อ',
|
||||
libraryRead: 'เรียกดูคลังสื่อ',
|
||||
libraryWrite: 'จัดการคลังสื่อ',
|
||||
terminal: 'เทอร์มินอลและสคริปต์',
|
||||
device: 'การตั้งค่าอุปกรณ์',
|
||||
network: 'การตั้งค่าเครือข่าย',
|
||||
update: 'อัปเดตระบบ',
|
||||
mcp: 'บริการ MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'ผู้ช่วย AI',
|
||||
effectConsoleView:
|
||||
'แสดงหน้าจอของเครื่องเป้าหมาย และกำหนดโหมดวิดีโอ ความละเอียด เฟรมเรต และคุณภาพที่ผู้ชมทุกคนใช้ร่วมกัน',
|
||||
effectConsoleControl: 'ส่งอินพุตคีย์บอร์ดและเมาส์ไปยังเครื่องเป้าหมาย',
|
||||
effectPower: 'เปิด ปิด และรีเซ็ตเครื่องเป้าหมาย และจัดการรายการอุปกรณ์ Wake-on-LAN',
|
||||
effectMediumAttach:
|
||||
'เมาท์และถอดสื่อบนเครื่องเป้าหมาย และแลกเปลี่ยนไฟล์กับเครื่องนั้นผ่านพาร์ทิชันที่แชร์',
|
||||
effectLibraryRead: 'แสดงรายการและดาวน์โหลดอิมเมจและไฟล์ภายใน',
|
||||
effectLibraryWrite: 'อัปโหลด สร้าง และลบอิมเมจ และแก้ไขไฟล์ภายใน',
|
||||
effectTerminal: 'ให้ root shell บนอุปกรณ์นี้โดยไม่ผ่านเซสชันเว็บ',
|
||||
effectDevice:
|
||||
'เปิดใช้ SSH (เป็น root เช่นกัน) รีสตาร์ทอุปกรณ์ ส่งพาร์ทิชันข้อมูลดิบให้เครื่องเป้าหมาย และเปลี่ยนการตั้งค่า OLED, HDMI, mDNS, หน่วยความจำ และ swap',
|
||||
effectNetwork: 'เปลี่ยน Wi-Fi, DNS และ TLS ซึ่งอาจทำให้อุปกรณ์นี้หลุดจากเครือข่าย',
|
||||
effectUpdate: 'ติดตั้งเฟิร์มแวร์และแพ็กเกจแอปพลิเคชัน',
|
||||
effectMcp:
|
||||
'ออกคีย์ API ระดับอุปกรณ์ที่ควบคุมคีย์บอร์ด เมาส์ และการจับภาพหน้าจอนอกเซสชันเว็บ สำเนาคีย์ยังใช้ได้หลังเพิกถอนสิทธิ์เว้นแต่จะสร้างคีย์ใหม่ ซึ่งจะมีตัวเลือกให้เมื่อนำสิทธิ์ออก',
|
||||
effectTailscale: 'เชื่อมอุปกรณ์นี้เข้ากับเครือข่าย VPN',
|
||||
effectAi:
|
||||
'ควบคุมอุปกรณ์ผ่านอินเทอร์เฟซภายในที่ไม่ถูกจำกัดด้วยสิทธิ์อื่น จึงเทียบเท่าการควบคุมอุปกรณ์ทั้งหมด',
|
||||
fullControlWarning: 'สิทธิ์เหล่านี้เทียบเท่าการควบคุมอุปกรณ์ทั้งหมด:',
|
||||
emptyWarning: 'บัญชีนี้จะไม่มีฟังก์ชันใดเลยและเข้าสู่ระบบได้เท่านั้น',
|
||||
impliedBy: 'รวมอยู่โดย {{name}}',
|
||||
grantCount_one: '{{count}} สิทธิ์',
|
||||
grantCount: '{{count}} สิทธิ์',
|
||||
adminAll: 'ผู้ดูแลระบบมีทุกสิทธิ์',
|
||||
catalogFailed: 'โหลดรายการสิทธิ์ไม่สำเร็จ ปิดหน้าต่างนี้แล้วลองใหม่'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const tr = {
|
||||
login: 'Giriş',
|
||||
changePassword: 'Şifreyi değiştir',
|
||||
terminal: 'Uçbirim',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Disk imajları ve dosya paylaşımı'
|
||||
},
|
||||
auth: {
|
||||
login: 'Giriş',
|
||||
@@ -40,7 +41,10 @@ const tr = {
|
||||
change3: 'Sistem yöneticisi şifresi (Güvenli Kabuk Bağlantısı (SSH) giriş şifresi)',
|
||||
change4:
|
||||
'Şifreleri sıfırlamak için NanoKVM üzerinde bulunan BOOT tuşuna 10 saniye boyunca basılı tutun.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Geçerli şifre',
|
||||
passwordLength: 'Şifre 8 ile 72 karakter arasında olmalıdır',
|
||||
invalidCurrentPassword: 'Geçerli şifre yanlış'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const tr = {
|
||||
verifyBtn: 'Doğrula'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Yalnızca izleme',
|
||||
readOnlyTip:
|
||||
'Bu hesap hedef makineye klavye veya fare girişi gönderemez; bu nedenle konsol yalnızca ekranı gösterir.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Bu hesabın konsol erişimi yok',
|
||||
description:
|
||||
'Hesaba başka yetkiler verilmiş. Kullanabileceği sayfalardan birini açın veya bir yöneticiden konsol erişimi isteyin.',
|
||||
nothing: 'Bu hesabın henüz hiçbir izni yok. Bir yöneticiden izin vermesini isteyin.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Ölçek',
|
||||
title: 'Ekran',
|
||||
@@ -213,33 +228,44 @@ const tr = {
|
||||
disable: 'Yalnızca HID modunu devre dışı bırak'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Disk İmajları',
|
||||
loading: 'Yükleniyor...',
|
||||
empty: 'Hiçbir şey bulunamadı',
|
||||
storage: {
|
||||
title: 'Disk imajları ve dosya paylaşımı',
|
||||
empty:
|
||||
'Henüz imaj yok. Bir ISO veya IMG yükleyin ya da hedef makineden diske bir tane kopyalayın.',
|
||||
notAttached: 'Hiçbir şey bağlı değil',
|
||||
attached: 'Bağlı: {{name}}',
|
||||
eject: 'Çıkar',
|
||||
mount: 'Bağla',
|
||||
mountMode: 'Montaj modu',
|
||||
mountFailed: 'Bağlantı başarısız oldu',
|
||||
mountDesc:
|
||||
'Bazı sistemlerde, disk imajını bağlamadan önce uzak ana bilgisayardaki sanal diski çıkarmak gerekir.',
|
||||
diskOnWarn:
|
||||
'İmajı bağlamak sanal diski kapatır, böylece bilgisayar /data dizinine erişemez. Devam edilsin mi?',
|
||||
diskOffWarn:
|
||||
'Sanal disk kapalı ve imajı bağlamak için açılması gerekiyor. Bilgisayardaki USB aygıtları yeniden tanımlanır. Devam edilsin mi?',
|
||||
diskShared:
|
||||
'Sanal disk açık, bu nedenle /data burada salt okunur. İmaj silmek veya yüklemek için sanal diski kapatın.',
|
||||
unmountFailed: 'Bağlantıyı kesme işlemi başarısız oldu',
|
||||
unmountDesc:
|
||||
'Bazı sistemlerde, görüntünün bağlantısını kesmeden önce uzak ana bilgisayardan manuel olarak çıkarmanız gerekir.',
|
||||
refresh: 'Disk imajı listesini yenile',
|
||||
attention: 'Dikkat',
|
||||
deleteConfirm: 'Bu resmi silmek istediğinizden emin misiniz?',
|
||||
okBtn: 'Evet',
|
||||
cancelBtn: 'Hayır',
|
||||
mountModeDisk: 'Yığın depolama',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Dosya yöneticisini aç',
|
||||
refresh: 'Yenile',
|
||||
delete: 'Sil',
|
||||
deleteTitle: 'Ortamı sil',
|
||||
deleteConfirm: '{{name}} silinsin mi? İçindeki dosyalar kaybolur.',
|
||||
downloadImage: 'İmajı indir',
|
||||
typePartition: 'Paylaşılan dosyalar',
|
||||
volumeLabel: 'Çıkarılabilir disk',
|
||||
groups: {
|
||||
share: 'Dosya paylaşımı',
|
||||
images: 'İmajlar'
|
||||
},
|
||||
failed: 'İşlem başarısız oldu',
|
||||
hidOnly: 'Yalnızca HID modu: sanal disk kullanılamıyor',
|
||||
disabled: 'Sanal disk devre dışı (Ayarlar - Aygıt)',
|
||||
force: {
|
||||
title: 'Ortam zorla geri alınsın mı?',
|
||||
desc: 'Hedef makine bu ortamı hâlâ kullanıyor. Geri almak USB aygıtını yeniden numaralandırır ve diske henüz yazmadığı her şeyi kaybedebilir.',
|
||||
menuHint:
|
||||
'Hedef makine bu ortamı kullanıyor. Zorla geri almak için dosya yöneticisini açın.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Nasıl yüklenir',
|
||||
usb1: "NanoKVM'i bilgisayarınıza USB ile bağlayın.",
|
||||
usb2: 'Sanal diskin bağlı olduğundan emin olun (Ayarlar - Sanal Disk).',
|
||||
usb2: 'Bu sayfada paylaşılan dosyaları yığın depolama modunda bağlayın.',
|
||||
usb3: 'Sanal diski bilgisayarınızda açın ve disk imajı dosyanızı sanal diskin kök dizinine kopyalayın.',
|
||||
usb4: 'Kopyalama bitince diski çıkarın: imaj ardından listede görünür.',
|
||||
scp1: 'NanoKVM ve bilgisayarınızın aynı yerel ağda bulunduğundan emin olun.',
|
||||
scp2: "Bilgisayarınızda uçbirimi açın ve disk imajı dosyanını SCP komudunu kullanarak NanoKVM'in /data dizinine yükleyin.",
|
||||
scp3: 'Örnek: scp senin-disk-imajı-dizinin root@senin-nanokvm-ip:/data',
|
||||
@@ -249,7 +275,68 @@ const tr = {
|
||||
tf3: 'micro SD kartı kart okuyucusuna takın ve bilgisayarınıza bağlayın.',
|
||||
tf4: 'Disk imajı dosyanını micro SD kartın /data dizinine kopyalayın.',
|
||||
tf5: "micro SD kartı NanoKVM'e geri yerleştirin."
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '{{name}} işleniyor'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Sanal disk etkinleştirilsin mi?',
|
||||
desc: 'Bir ortamı bağlamak için şu anda kapalı olan sanal disk gerekir. Şimdi etkinleştirilsin mi? Hedef makinenin klavyesi ve faresinin bağlantısı bir an kesilir.',
|
||||
menuHint: 'Sanal disk devre dışı. Ayarlar - Aygıt bölümünden etkinleştirin.',
|
||||
askDevice:
|
||||
'Sanal disk kapalı. Önce cihaz iznine sahip bir hesabın etkinleştirmesi gerekiyor.'
|
||||
},
|
||||
add: {
|
||||
title: 'Ortam ekle',
|
||||
upload: 'ISO veya IMG yükle',
|
||||
fromUrl: "URL'den indir",
|
||||
url: "İmaj URL'si",
|
||||
urlRequired: 'Lütfen bir URL girin',
|
||||
onlyImages: 'Yalnızca .iso ve .img dosyaları bağlanabilir',
|
||||
dropHint: 'ISO veya IMG dosyasını buraya sürükleyin ya da seçmek için tıklayın',
|
||||
cdromOnlyIso: 'Bir CD-ROM yalnızca ISO 9660 imajı taşıyabilir',
|
||||
uploadFailed: 'Yükleme başarısız oldu',
|
||||
downloadFailed: 'İndirme başarısız oldu',
|
||||
uploading: '{{name}} yükleniyor',
|
||||
downloading: '{{name}} indiriliyor',
|
||||
done: '{{name}} hazır',
|
||||
checksumFailed: 'İmaj, SHA-256 sağlama toplamıyla eşleşmiyor.',
|
||||
blocked: 'Hedef makine diski kullanıyor',
|
||||
copyHint: 'İmajı SCP ile veri bölümüne ya da hedef makineden doğrudan kopyalayabilirsiniz.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Dosyalarını görmek için bir ortam seçin.',
|
||||
reasonAttached: 'Bu ortam hedef makineye bağlı.',
|
||||
reasonAttachedHint:
|
||||
'Önce çıkarın: dosyalarının okunabilmesi için hedef makinenin diski bırakması gerekir.',
|
||||
reasonNotBrowsable: 'Bu ortam, dosya yöneticisinin açamadığı bir dosya sistemi içeriyor.',
|
||||
reasonNoLoop: 'Bu aygıt disk imajlarını bağlayamıyor (loop desteği yok).',
|
||||
reasonISOImage:
|
||||
'ISO 9660 imajının içeriği burada görüntülenemez. İmaj hedef makineye yine bağlanabilir.',
|
||||
empty: 'Bu klasör boş',
|
||||
noMatch: 'Aramanızla eşleşen dosya yok.',
|
||||
free: '{{size}} boş',
|
||||
search: 'Bu klasörde ara',
|
||||
sort: {
|
||||
name: 'Ad',
|
||||
size: 'Boyut',
|
||||
type: 'Tür',
|
||||
asc: 'Artan',
|
||||
desc: 'Azalan'
|
||||
},
|
||||
upload: 'Ortama dosya yükle',
|
||||
deleteFolderTitle: 'Klasörü sil',
|
||||
deleteFolderConfirm:
|
||||
'{{name}} klasörü ve içindeki her şey silinsin mi? Bu işlem geri alınamaz.',
|
||||
uploading: 'Yükleniyor…',
|
||||
uploadFailed: 'Yükleme başarısız oldu',
|
||||
download: 'İndir',
|
||||
delete: 'Sil',
|
||||
deleteTitle: 'Dosyayı sil',
|
||||
deleteConfirm: '{{name}} ortamdan silinsin mi?'
|
||||
},
|
||||
yes: 'Evet',
|
||||
no: 'Hayır'
|
||||
},
|
||||
script: {
|
||||
title: 'Betikler',
|
||||
@@ -291,21 +378,11 @@ const tr = {
|
||||
ok: 'Tamam'
|
||||
},
|
||||
download: {
|
||||
title: 'Disk İmajı İndirici',
|
||||
input: 'Uzak imaj URL’sini girin',
|
||||
ok: 'Tamam',
|
||||
disabled: '/data bölüntüsü salt okunur modda, disk imajı indirilemiyor.',
|
||||
diskShared: 'Önce sanal diski kapatın, sonra imaj indirmeyi kullanın.',
|
||||
diskOff: 'Sanal diski kapat',
|
||||
uploadbox: 'Dosyayı buraya bırakın veya seçmek için tıklayın',
|
||||
inputfile: 'Lütfen resim dosyasını giriniz',
|
||||
NoISO: 'ISO yok',
|
||||
disabled:
|
||||
'Veri bölümü, hedef makine kullandığı sürece salt okunurdur: yeni imaj yazılamaz, mevcut dosyalar indirilebilir.',
|
||||
sha256: 'SHA-256 (isteğe bağlı)',
|
||||
sha256Placeholder: '64 karakterlik SHA-256 sağlama toplamını girin',
|
||||
invalidSHA256: 'SHA-256, 64 karakterlik bir onaltılık dize olmalıdır',
|
||||
failed: 'İndirme başarısız',
|
||||
success: 'İndirme başarılı',
|
||||
checksumFailed: 'İndirme başarısız: SHA-256 doğrulaması başarısız',
|
||||
cancel: 'İptal',
|
||||
cancelFailed: 'İndirme iptal edilemedi'
|
||||
},
|
||||
@@ -457,16 +534,18 @@ const tr = {
|
||||
'Yalnızca temel HID kontrolünü koruyarak sanal aygıtları taklit etmeyi bırakın',
|
||||
disk: 'Sanal Disk',
|
||||
diskDesc: "Sanal U-disk'i uzak ana bilgisayara bağla",
|
||||
diskLocked: 'Bir imaj bağlıyken kapalı (açmak bağlamayı iptal eder)',
|
||||
diskCancelMount:
|
||||
'Sanal diski açmak, bağlı imajı iptal eder ve sürmekte olan imaj indirme veya yüklemeyi keser. Devam edilsin mi?',
|
||||
diskBreakDownload: 'Sanal diski açmak imaj indirmeyi keser. Devam edilsin mi?',
|
||||
network: 'Sanal Ağ',
|
||||
networkDesc: 'Sanal ağ kartını uzak ana bilgisayara bağla',
|
||||
reboot: 'Yeniden Başlat',
|
||||
rebootDesc: "NanoKVM'i yeniden başlatmak istediğinizden emin misiniz?",
|
||||
okBtn: 'Evet',
|
||||
cancelBtn: 'Hayır'
|
||||
cancelBtn: 'Hayır',
|
||||
diskLocked: 'Bir imaj bağlıyken kapalı (açmak bağlamayı iptal eder)',
|
||||
diskCancelMount:
|
||||
'Sanal diski açmak, bağlı imajı iptal eder ve sürmekte olan imaj indirme veya yüklemeyi keser. Devam edilsin mi?',
|
||||
diskBreakDownload: 'Sanal diski açmak imaj indirmeyi keser. Devam edilsin mi?',
|
||||
attention: 'Dikkat',
|
||||
updateFailed: 'Sanal cihaz güncellenemedi'
|
||||
},
|
||||
network: {
|
||||
title: 'Ağ',
|
||||
@@ -604,7 +683,89 @@ const tr = {
|
||||
logoutBtn: 'Çıkış yap',
|
||||
logoutDesc: 'Çıkış yapmak istediğinizden emin misiniz?',
|
||||
okBtn: 'Evet',
|
||||
cancelBtn: 'Hayır'
|
||||
cancelBtn: 'Hayır',
|
||||
role: 'Rol',
|
||||
roles: {
|
||||
admin: 'Yönetici',
|
||||
user: 'Kullanıcı'
|
||||
},
|
||||
users: {
|
||||
title: 'Kullanıcılar',
|
||||
create: 'Kullanıcı oluştur',
|
||||
enabled: 'Etkin',
|
||||
disabled: 'Devre dışı',
|
||||
deviceOwner: 'Aygıt sahibi',
|
||||
resetPassword: 'Şifreyi sıfırla',
|
||||
delete: 'Sil',
|
||||
deleteConfirm: 'Bu kullanıcı silinsin ve tüm oturumları iptal edilsin mi?',
|
||||
created: 'Kullanıcı oluşturuldu',
|
||||
deleted: 'Kullanıcı silindi',
|
||||
passwordUpdated: 'Şifre güncellendi',
|
||||
loadFailed: 'Kullanıcılar yüklenemedi',
|
||||
saveFailed: 'Kullanıcı kaydedilemedi',
|
||||
deleteFailed: 'Kullanıcı silinemedi',
|
||||
editPermissions: 'İzinler',
|
||||
mcpLossTitle: 'MCP erişimini kaldır',
|
||||
mcpLossDesc:
|
||||
'{{name}} MCP erişimini kaybedecek. MCP API anahtarı tüm cihazda ortaktır; bu yüzden bir kopyası anahtar yeniden üretilene kadar çalışır. Yeniden üretmek, yeni anahtarı alana kadar tüm MCP istemcilerinin bağlantısını keser.',
|
||||
mcpLossRotate: 'MCP API anahtarını yeniden üret',
|
||||
mcpLossKeep:
|
||||
'Mevcut anahtar geçerli kalır ve kopyası olan herkes klavye ve fareyi kontrol etmeye devam edebilir. Daha sonra MCP ayarlarından yeniden üretebilirsiniz.',
|
||||
mcpKeyRotated:
|
||||
'MCP API anahtarı yeniden üretildi. MCP istemcilerinin yeni anahtara ihtiyacı var.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'İzinler',
|
||||
editTitle: 'İzinleri düzenle',
|
||||
save: 'Kaydet',
|
||||
saved: 'Kaydedildi',
|
||||
close: 'Kapat',
|
||||
groupConsole: 'Konsol',
|
||||
groupTarget: 'Hedef makine',
|
||||
groupLibrary: 'Medya kitaplığı',
|
||||
groupHost: 'Bu cihaz',
|
||||
groupServices: 'Hizmetler',
|
||||
consoleView: 'Ekranı izle',
|
||||
consoleControl: 'Klavye ve fare',
|
||||
power: 'Güç ve uyandırma',
|
||||
mediumAttach: 'Medya bağla',
|
||||
libraryRead: 'Kitaplığa göz at',
|
||||
libraryWrite: 'Kitaplığı yönet',
|
||||
terminal: 'Terminal ve betikler',
|
||||
device: 'Cihaz ayarları',
|
||||
network: 'Ağ ayarları',
|
||||
update: 'Sistem güncellemesi',
|
||||
mcp: 'MCP hizmeti',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Yapay zekâ asistanı',
|
||||
effectConsoleView:
|
||||
'Hedef ekranı gösterir ve tüm izleyicilerin paylaştığı video modunu, çözünürlüğü, kare hızını ve kaliteyi ayarlar.',
|
||||
effectConsoleControl: 'Klavye ve fare girdisini hedef makineye gönderir.',
|
||||
effectPower:
|
||||
'Hedef makineyi açar, kapatır ve yeniden başlatır; Wake-on-LAN cihaz listesini yönetir.',
|
||||
effectMediumAttach:
|
||||
'Hedef makinede medya bağlar ve çıkarır, paylaşılan bölüm üzerinden dosya alışverişi yapar.',
|
||||
effectLibraryRead: 'İmajları ve içlerindeki dosyaları listeler ve indirir.',
|
||||
effectLibraryWrite:
|
||||
'İmaj yükler, oluşturur ve siler; imajların içindeki dosyaları değiştirir.',
|
||||
effectTerminal: 'Web oturumuna girmeden bu cihazda root kabuğu verir.',
|
||||
effectDevice:
|
||||
'SSH’yi etkinleştirir (o da root), cihazı yeniden başlatır, ham veri bölümünü hedef makineye verir; OLED, HDMI, mDNS, bellek ve takas ayarlarını değiştirir.',
|
||||
effectNetwork: 'Wi-Fi, DNS ve TLS ayarlarını değiştirir; bu cihaz ağdan düşebilir.',
|
||||
effectUpdate: 'Ürün yazılımı ve uygulama paketleri kurar.',
|
||||
effectMcp:
|
||||
'Cihaz genelinde geçerli bir API anahtarı verir; bu anahtar web oturumu dışında klavye, fare ve ekran görüntüsünü yönetir. İzin kaldırıldıktan sonra kopyalanmış anahtar, yeniden üretilmedikçe çalışır; izin kaldırılırken yeniden üretme önerilir.',
|
||||
effectTailscale: 'Bu cihazı bir VPN ağına bağlar.',
|
||||
effectAi:
|
||||
'Cihazı, diğer izinlerin sınırlamadığı yerel bir arayüz üzerinden yönetir; bu nedenle cihaz üzerinde tam denetim anlamına gelir.',
|
||||
fullControlWarning: 'Bu izinler cihaz üzerinde tam denetim anlamına gelir:',
|
||||
emptyWarning: 'Bu hesabın hiçbir özelliği olmayacak ve yalnızca oturum açabilecek.',
|
||||
impliedBy: '{{name}} tarafından içerilir',
|
||||
grantCount_one: '{{count}} izin',
|
||||
grantCount: '{{count}} izin',
|
||||
adminAll: 'Yöneticiler tüm izinlere sahiptir',
|
||||
catalogFailed: 'İzin listesi yüklenemedi. Bu pencereyi kapatıp yeniden deneyin.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const uk = {
|
||||
login: 'Вхід',
|
||||
changePassword: 'Змінити пароль',
|
||||
terminal: 'Термінал',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Образи та обмін файлами'
|
||||
},
|
||||
auth: {
|
||||
login: 'Вхід',
|
||||
@@ -40,7 +41,10 @@ const uk = {
|
||||
change2: 'Пароль для входу у веб-інтерфейс',
|
||||
change3: 'Системний root пароль (пароль для входу в систему по SSH)',
|
||||
change4: 'Щоб скинути паролі, натисніть і утримуйте кнопку BOOT на NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Поточний пароль',
|
||||
passwordLength: 'Пароль має містити від 8 до 72 символів',
|
||||
invalidCurrentPassword: 'Поточний пароль неправильний'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -59,6 +63,17 @@ const uk = {
|
||||
verifyBtn: 'Перевірити'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Лише перегляд',
|
||||
readOnlyTip:
|
||||
'Цей обліковий запис не може надсилати введення з клавіатури й миші на цільову машину, тому консоль лише показує екран.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Цей обліковий запис не має доступу до консолі',
|
||||
description:
|
||||
'Йому надано інші можливості. Відкрийте одну з доступних сторінок або попросіть адміністратора надати доступ до консолі.',
|
||||
nothing: 'Цей обліковий запис ще не має дозволів. Попросіть адміністратора їх надати.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Масштаб',
|
||||
title: 'Екран',
|
||||
@@ -215,33 +230,44 @@ const uk = {
|
||||
disable: 'Ввимкнути режим "лише HID"'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Образи',
|
||||
loading: 'Завантаження...',
|
||||
empty: 'Нічого не знайдено',
|
||||
storage: {
|
||||
title: 'Образи та обмін файлами',
|
||||
empty:
|
||||
"Образів ще немає. Завантажте ISO або IMG чи скопіюйте образ на диск із цільового комп'ютера.",
|
||||
notAttached: 'Нічого не підключено',
|
||||
attached: 'Підключено: {{name}}',
|
||||
eject: 'Витягти',
|
||||
mount: 'Підключити',
|
||||
mountMode: 'Режим монтування',
|
||||
mountFailed: 'Не вдалося змонтувати образ у режимі CD-ROM',
|
||||
mountDesc:
|
||||
'У деяких системах необхідно витягнути віртуальний диск на віддаленому хості перед монтуванням файлу образа.',
|
||||
diskOnWarn:
|
||||
"Монтування образу вимикає віртуальний диск, тому комп'ютер більше не має доступу до /data. Продовжити?",
|
||||
diskOffWarn:
|
||||
"Віртуальний диск вимкнено, його треба увімкнути для монтування образу. USB-пристрої комп'ютера буде перепідключено. Продовжити?",
|
||||
diskShared:
|
||||
'Віртуальний диск увімкнено, тому /data тут лише для читання. Вимкніть його, щоб видаляти або завантажувати образи.',
|
||||
unmountFailed: 'Не вдалося розмонтувати',
|
||||
unmountDesc:
|
||||
'У деяких системах вам потрібно вручну вийти з віддаленого хоста перед демонтуванням образу.',
|
||||
refresh: 'Оновіть список образів',
|
||||
attention: 'Увага',
|
||||
deleteConfirm: 'Ви впевнені, що хочете видалити це зображення?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні',
|
||||
mountModeDisk: 'Знімний диск',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Відкрити файловий менеджер',
|
||||
refresh: 'Оновити',
|
||||
delete: 'Видалити',
|
||||
deleteTitle: 'Видалити носій',
|
||||
deleteConfirm: 'Видалити {{name}}? Файли в ньому буде втрачено.',
|
||||
downloadImage: 'Завантажити образ',
|
||||
typePartition: 'Спільні файли',
|
||||
volumeLabel: 'Знімний диск',
|
||||
groups: {
|
||||
share: 'Обмін файлами',
|
||||
images: 'Образи'
|
||||
},
|
||||
failed: 'Операція не вдалася',
|
||||
hidOnly: 'Режим лише HID: віртуальний диск недоступний',
|
||||
disabled: 'Віртуальний диск вимкнено (Налаштування - Пристрій)',
|
||||
force: {
|
||||
title: 'Забрати носій примусово?',
|
||||
desc: "Цільовий комп'ютер усе ще використовує цей носій. Примусове вилучення заново перелічить USB-пристрій і може втратити все, що ще не записано на диск.",
|
||||
menuHint:
|
||||
"Цільовий комп'ютер використовує цей носій. Відкрийте файловий менеджер, щоб забрати його примусово."
|
||||
},
|
||||
tips: {
|
||||
title: 'Як завантажити',
|
||||
usb1: "Під'єднайте NanoKVM до вашого комп'ютера через USB.",
|
||||
usb2: 'Переконайтеся, що віртуальний диск змонтовано (Налаштування - Віртуальний диск).',
|
||||
usb2: 'На цій сторінці підключіть спільні файли в режимі накопичувача.',
|
||||
usb3: "Відкрийте віртуальний диск на вашому комп'ютері та скопіюйте файл зображення до кореневого каталогу віртуального диска.",
|
||||
usb4: "Після копіювання вийміть диск: образ з'явиться у списку.",
|
||||
scp1: "Переконайтеся, що NanoKVM і ваш комп'ютер знаходяться в одній локальній мережі.",
|
||||
scp2: "Відкрийте термінал на вашому комп'ютері та використовуйте команду SCP для завантаження файлу зображення до каталогу /data на NanoKVM.",
|
||||
scp3: 'Приклад: scp ваш-шлях-до-зображення root@ваш-ip-nanokvm:/data',
|
||||
@@ -251,7 +277,68 @@ const uk = {
|
||||
tf3: "Вставте TF карту в кардрідер і під'єднайте її до вашого комп'ютера.",
|
||||
tf4: 'Скопіюйте файл зображення до каталогу /data на TF карті.',
|
||||
tf5: 'Вставте TF карту в NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Обробка {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Увімкнути віртуальний диск?',
|
||||
desc: "Для підключення носія потрібен віртуальний диск, який зараз вимкнено. Увімкнути його? Клавіатура й миша цільового комп'ютера на мить відключаться.",
|
||||
menuHint: 'Віртуальний диск вимкнено. Увімкніть його в розділі Налаштування - Пристрій.',
|
||||
askDevice:
|
||||
'Віртуальний диск вимкнено. Спочатку його має увімкнути обліковий запис із дозволом на пристрій.'
|
||||
},
|
||||
add: {
|
||||
title: 'Додати носій',
|
||||
upload: 'Завантажити ISO або IMG',
|
||||
fromUrl: 'Завантажити за URL-адресою',
|
||||
url: 'URL-адреса образу',
|
||||
urlRequired: 'Введіть URL-адресу',
|
||||
onlyImages: 'Підключати можна лише файли .iso та .img',
|
||||
dropHint: 'Перетягніть сюди ISO або IMG чи натисніть, щоб вибрати файл',
|
||||
cdromOnlyIso: 'На CD-ROM можна записати лише образ ISO 9660',
|
||||
uploadFailed: 'Не вдалося завантажити',
|
||||
downloadFailed: 'Не вдалося звантажити',
|
||||
uploading: 'Завантаження {{name}}',
|
||||
downloading: 'Звантаження {{name}}',
|
||||
done: '{{name}} готовий',
|
||||
checksumFailed: 'Образ не збігається з контрольною сумою SHA-256.',
|
||||
blocked: "Цільовий комп'ютер використовує диск",
|
||||
copyHint:
|
||||
'Образ також можна скопіювати в розділ даних через SCP або з цільового комп’ютера.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Виберіть носій, щоб переглянути файли на ньому.',
|
||||
reasonAttached: "Цей носій підключено до цільового комп'ютера.",
|
||||
reasonAttachedHint:
|
||||
"Спочатку витягніть його: цільовий комп'ютер має звільнити диск, перш ніж його файли можна буде прочитати.",
|
||||
reasonNotBrowsable: 'Цей носій містить файлову систему, яку менеджер не може відкрити.',
|
||||
reasonNoLoop: 'Цей пристрій не може підключати образи дисків (немає підтримки loop).',
|
||||
reasonISOImage:
|
||||
"Вміст образу ISO 9660 тут не переглядається. Сам образ усе ще можна підключити до цільового комп'ютера.",
|
||||
empty: 'Ця тека порожня',
|
||||
noMatch: 'Жоден файл не відповідає пошуку.',
|
||||
free: '{{size}} вільно',
|
||||
search: 'Пошук у цій теці',
|
||||
sort: {
|
||||
name: "Ім'я",
|
||||
size: 'Розмір',
|
||||
type: 'Тип',
|
||||
asc: 'За зростанням',
|
||||
desc: 'За спаданням'
|
||||
},
|
||||
upload: 'Завантажити файл на носій',
|
||||
deleteFolderTitle: 'Видалити теку',
|
||||
deleteFolderConfirm: 'Видалити теку {{name}} з усім вмістом? Цю дію неможливо скасувати.',
|
||||
uploading: 'Завантаження…',
|
||||
uploadFailed: 'Не вдалося завантажити',
|
||||
download: 'Звантажити',
|
||||
delete: 'Видалити',
|
||||
deleteTitle: 'Видалити файл',
|
||||
deleteConfirm: 'Видалити {{name}} з носія?'
|
||||
},
|
||||
yes: 'Так',
|
||||
no: 'Ні'
|
||||
},
|
||||
script: {
|
||||
title: 'Скрипти',
|
||||
@@ -293,22 +380,11 @@ const uk = {
|
||||
ok: 'Ок'
|
||||
},
|
||||
download: {
|
||||
title: 'Завантажувач образів',
|
||||
input: 'Введіть URL-адресу віддаленого образу',
|
||||
ok: 'Так',
|
||||
disabled: 'Розділ даних /data у режимі лише для читання, тому ми не можемо завантажити образ',
|
||||
diskShared:
|
||||
'Спочатку вимкніть віртуальний диск, а потім скористайтеся завантаженням образів.',
|
||||
diskOff: 'Вимкнути віртуальний диск',
|
||||
uploadbox: 'Перетягніть файл сюди або натисніть, щоб вибрати',
|
||||
inputfile: 'Будь ласка, введіть файл зображення',
|
||||
NoISO: 'Немає ISO',
|
||||
disabled:
|
||||
'Розділ даних доступний лише для читання, поки його використовує цільовий комп’ютер: нові образи записати не можна, але наявні файли все одно можна завантажити.',
|
||||
sha256: 'SHA-256 (необов’язково)',
|
||||
sha256Placeholder: 'Введіть контрольну суму SHA-256 із 64 символів',
|
||||
invalidSHA256: 'SHA-256 має бути шістнадцятковим рядком із 64 символів',
|
||||
failed: 'Помилка завантаження',
|
||||
success: 'Завантаження успішне',
|
||||
checksumFailed: 'Помилка завантаження: перевірка SHA-256 не пройдена',
|
||||
cancel: 'Скасувати',
|
||||
cancelFailed: 'Не вдалося скасувати завантаження'
|
||||
},
|
||||
@@ -456,17 +532,19 @@ const uk = {
|
||||
hidOnlyDesc: 'Зупиніть емуляцію віртуальних пристроїв, зберігши лише базовий контроль HID',
|
||||
disk: 'Віртуальний диск',
|
||||
diskDesc: 'Монтувати віртуальний U-диск на віддаленому хості',
|
||||
diskLocked: 'Вимкнено, поки змонтовано образ (увімкнення скасує монтування)',
|
||||
diskCancelMount:
|
||||
'Увімкнення віртуального диска скасує змонтований образ і перерве поточне завантаження чи вивантаження образу. Продовжити?',
|
||||
diskBreakDownload:
|
||||
'Увімкнення віртуального диска перерве завантаження образів. Продовжити?',
|
||||
network: 'Віртуальна мережа',
|
||||
networkDesc: 'Встановити віртуальну мережеву карту на віддаленому хості',
|
||||
reboot: 'Перезавантажити',
|
||||
rebootDesc: 'Ви впевнені, що хочете перезавантажити NanoKVM?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
cancelBtn: 'Ні',
|
||||
diskLocked: 'Вимкнено, поки змонтовано образ (увімкнення скасує монтування)',
|
||||
diskCancelMount:
|
||||
'Увімкнення віртуального диска скасує змонтований образ і перерве поточне завантаження чи вивантаження образу. Продовжити?',
|
||||
diskBreakDownload:
|
||||
'Увімкнення віртуального диска перерве завантаження образів. Продовжити?',
|
||||
attention: 'Увага',
|
||||
updateFailed: 'Не вдалося оновити віртуальний пристрій'
|
||||
},
|
||||
network: {
|
||||
title: 'Мережа',
|
||||
@@ -605,7 +683,88 @@ const uk = {
|
||||
logoutBtn: 'Вийти',
|
||||
logoutDesc: 'Ви впевнені, що хочете вийти?',
|
||||
okBtn: 'Так',
|
||||
cancelBtn: 'Ні'
|
||||
cancelBtn: 'Ні',
|
||||
role: 'Роль',
|
||||
roles: {
|
||||
admin: 'Адміністратор',
|
||||
user: 'Користувач'
|
||||
},
|
||||
users: {
|
||||
title: 'Користувачі',
|
||||
create: 'Створити користувача',
|
||||
enabled: 'Увімкнено',
|
||||
disabled: 'Вимкнено',
|
||||
deviceOwner: 'Власник пристрою',
|
||||
resetPassword: 'Скинути пароль',
|
||||
delete: 'Видалити',
|
||||
deleteConfirm: 'Видалити цього користувача й відкликати всі його сеанси?',
|
||||
created: 'Користувача створено',
|
||||
deleted: 'Користувача видалено',
|
||||
passwordUpdated: 'Пароль оновлено',
|
||||
loadFailed: 'Не вдалося завантажити користувачів',
|
||||
saveFailed: 'Не вдалося зберегти користувача',
|
||||
deleteFailed: 'Не вдалося видалити користувача',
|
||||
editPermissions: 'Дозволи',
|
||||
mcpLossTitle: 'Відкликати доступ до MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} втратить доступ до MCP. Ключ API MCP спільний для всього пристрою, тож його копія працює, доки ключ не створено заново. Повторне створення відключить усі клієнти MCP, доки вони не отримають новий ключ.',
|
||||
mcpLossRotate: 'Створити новий ключ API MCP',
|
||||
mcpLossKeep:
|
||||
'Поточний ключ залишиться чинним, і власник копії й надалі зможе керувати клавіатурою та мишею. Пізніше його можна створити заново в налаштуваннях MCP.',
|
||||
mcpKeyRotated: 'Ключ API MCP створено заново. Клієнтам MCP потрібен новий ключ.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Дозволи',
|
||||
editTitle: 'Змінити дозволи',
|
||||
save: 'Зберегти',
|
||||
saved: 'Збережено',
|
||||
close: 'Закрити',
|
||||
groupConsole: 'Консоль',
|
||||
groupTarget: 'Цільова машина',
|
||||
groupLibrary: 'Бібліотека носіїв',
|
||||
groupHost: 'Цей пристрій',
|
||||
groupServices: 'Сервіси',
|
||||
consoleView: 'Перегляд екрана',
|
||||
consoleControl: 'Клавіатура та миша',
|
||||
power: 'Живлення й пробудження',
|
||||
mediumAttach: 'Підключити носій',
|
||||
libraryRead: 'Перегляд бібліотеки',
|
||||
libraryWrite: 'Керування бібліотекою',
|
||||
terminal: 'Термінал і скрипти',
|
||||
device: 'Налаштування пристрою',
|
||||
network: 'Налаштування мережі',
|
||||
update: 'Оновлення системи',
|
||||
mcp: 'Сервіс MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'ШІ-асистент',
|
||||
effectConsoleView:
|
||||
'Показує екран цільової машини й задає режим відео, роздільну здатність, частоту кадрів і якість, спільні для всіх глядачів.',
|
||||
effectConsoleControl: 'Передає введення з клавіатури та миші на цільову машину.',
|
||||
effectPower:
|
||||
'Вмикає, вимикає та перезавантажує цільову машину, а також керує списком пристроїв Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Підключає й відключає носії на цільовій машині та обмінюється з нею файлами через спільний розділ.',
|
||||
effectLibraryRead: 'Показує й завантажує образи та файли всередині них.',
|
||||
effectLibraryWrite: 'Завантажує, створює й видаляє образи та змінює файли всередині них.',
|
||||
effectTerminal: 'Дає root-оболонку на цьому пристрої, минаючи вебсеанс.',
|
||||
effectDevice:
|
||||
'Дозволяє увімкнути SSH (теж root), перезавантажити пристрій, передати цільовій машині необроблений розділ даних і змінити налаштування OLED, HDMI, mDNS, пам’яті та swap.',
|
||||
effectNetwork: 'Змінює Wi-Fi, DNS і TLS, що може від’єднати цей пристрій від мережі.',
|
||||
effectUpdate: 'Встановлює прошивку та пакети застосунків.',
|
||||
effectMcp:
|
||||
'Видає ключ API, чинний для всього пристрою: він керує клавіатурою, мишею та знімками екрана поза вебсеансом; копія ключа працює і після відкликання дозволу, якщо ключ не створити заново — це пропонується під час зняття дозволу.',
|
||||
effectTailscale: 'Підключає цей пристрій до мережі VPN.',
|
||||
effectAi:
|
||||
'Керує пристроєм через локальний інтерфейс, який не обмежується іншими дозволами, тому це фактично повний контроль над пристроєм.',
|
||||
fullControlWarning: 'Ці дозволи означають повний контроль над пристроєм:',
|
||||
emptyWarning: 'Цей обліковий запис не матиме жодних функцій і зможе лише входити.',
|
||||
impliedBy: 'Включено дозволом {{name}}',
|
||||
grantCount_one: 'Дозволів: {{count}}',
|
||||
grantCount: 'Дозволів: {{count}}',
|
||||
adminAll: 'Адміністратори мають усі дозволи',
|
||||
catalogFailed:
|
||||
'Не вдалося завантажити список дозволів. Закрийте вікно й спробуйте ще раз.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -5,7 +5,8 @@ const vi = {
|
||||
login: 'Đăng nhập',
|
||||
changePassword: 'Đổi mật khẩu',
|
||||
terminal: 'Terminal',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: 'Hình ảnh và chia sẻ tệp'
|
||||
},
|
||||
auth: {
|
||||
login: 'Đăng nhập',
|
||||
@@ -39,7 +40,10 @@ const vi = {
|
||||
change2: 'Mật khẩu đăng nhập web',
|
||||
change3: 'Mật khẩu root hệ thống (mật khẩu đăng nhập SSH)',
|
||||
change4: 'Để đặt lại mật khẩu, hãy nhấn và giữ nút BOOT trên NanoKVM.'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: 'Mật khẩu hiện tại',
|
||||
passwordLength: 'Mật khẩu phải từ 8 đến 72 ký tự',
|
||||
invalidCurrentPassword: 'Mật khẩu hiện tại không đúng'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -58,6 +62,17 @@ const vi = {
|
||||
verifyBtn: 'Xác minh'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: 'Chỉ xem',
|
||||
readOnlyTip:
|
||||
'Tài khoản này không thể gửi dữ liệu bàn phím hoặc chuột tới máy đích, vì vậy console chỉ hiển thị màn hình.'
|
||||
},
|
||||
noConsole: {
|
||||
title: 'Tài khoản này không có quyền console',
|
||||
description:
|
||||
'Tài khoản đã được cấp các khả năng khác. Hãy mở một trong các trang có thể dùng, hoặc đề nghị quản trị viên cấp quyền console.',
|
||||
nothing: 'Tài khoản này chưa có quyền nào. Hãy đề nghị quản trị viên cấp quyền.'
|
||||
},
|
||||
screen: {
|
||||
scale: 'Quy mô',
|
||||
title: 'Màn hình',
|
||||
@@ -213,32 +228,43 @@ const vi = {
|
||||
disable: 'Tắt chế độ HID-Chỉ'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'Hình ảnh',
|
||||
loading: 'Đang tải...',
|
||||
empty: 'Không tìm thấy',
|
||||
storage: {
|
||||
title: 'Hình ảnh và chia sẻ tệp',
|
||||
empty:
|
||||
'Chưa có hình ảnh nào. Hãy tải lên tệp ISO hoặc IMG, hoặc sao chép một tệp vào đĩa từ máy đích.',
|
||||
notAttached: 'Chưa gắn kết gì',
|
||||
attached: 'Đã gắn kết: {{name}}',
|
||||
eject: 'Đẩy ra',
|
||||
mount: 'Gắn kết',
|
||||
mountMode: 'Chế độ gắn kết',
|
||||
mountFailed: 'Mount thất bại',
|
||||
mountDesc: 'Trong một số hệ thống, cần phải eject đĩa ảo trên máy remote trước khi mount.',
|
||||
diskOnWarn:
|
||||
'Mounting the image turns the virtual disk off, so the computer can no longer access /data. Continue?',
|
||||
diskOffWarn:
|
||||
'The virtual disk is off and has to be enabled to mount the image. The USB devices on the computer are enumerated again. Continue?',
|
||||
diskShared:
|
||||
'Virtual disk is enabled, so /data is read-only here. Disable it to delete or upload images.',
|
||||
unmountFailed: 'Tháo lắp không thành công',
|
||||
unmountDesc:
|
||||
'Trên một số hệ thống, bạn cần đẩy hình ảnh ra khỏi máy chủ từ xa theo cách thủ công trước khi ngắt kết nối hình ảnh.',
|
||||
refresh: 'Làm mới danh sách hình ảnh',
|
||||
attention: 'Chú ý',
|
||||
deleteConfirm: 'Bạn có chắc chắn muốn xóa hình ảnh này không?',
|
||||
okBtn: 'Có',
|
||||
cancelBtn: 'Không',
|
||||
mountModeDisk: 'Lưu trữ hàng loạt',
|
||||
mountModeCdrom: 'CD-ROM',
|
||||
openManager: 'Mở trình quản lý tệp',
|
||||
refresh: 'Làm mới',
|
||||
delete: 'Xóa',
|
||||
deleteTitle: 'Xóa phương tiện',
|
||||
deleteConfirm: 'Xóa {{name}}? Các tệp bên trong sẽ bị mất.',
|
||||
downloadImage: 'Tải hình ảnh xuống',
|
||||
typePartition: 'Tệp chia sẻ',
|
||||
volumeLabel: 'Đĩa di động',
|
||||
groups: {
|
||||
share: 'Chia sẻ tệp',
|
||||
images: 'Hình ảnh'
|
||||
},
|
||||
failed: 'Thao tác không thành công',
|
||||
hidOnly: 'Chế độ chỉ HID: đĩa ảo không khả dụng',
|
||||
disabled: 'Đĩa ảo đang tắt (Cài đặt - Thiết bị)',
|
||||
force: {
|
||||
title: 'Cưỡng chế lấy lại phương tiện?',
|
||||
desc: 'Máy đích vẫn đang dùng phương tiện này. Lấy lại sẽ liệt kê lại thiết bị USB và có thể mất những gì nó chưa ghi vào đĩa.',
|
||||
menuHint: 'Máy đích đang dùng phương tiện này. Mở trình quản lý tệp để cưỡng chế lấy lại.'
|
||||
},
|
||||
tips: {
|
||||
title: 'Cách tải lên',
|
||||
usb1: 'Kết nối NanoKVM với máy tính của bạn qua USB.',
|
||||
usb2: 'Đảm bảo rằng đĩa ảo đã được gắn kết (Cài đặt - Đĩa ảo).',
|
||||
usb2: 'Trên trang này, gắn các tệp chia sẻ ở chế độ lưu trữ dung lượng lớn.',
|
||||
usb3: 'Mở đĩa ảo trên máy tính của bạn và sao chép vào thư mục gốc của đĩa ảo.',
|
||||
usb4: 'Đẩy đĩa ra khi sao chép xong: ảnh đĩa sẽ xuất hiện trong danh sách.',
|
||||
scp1: 'Đảm bảo NanoKVM và máy tính của bạn đang trên cùng một mạng nội bộ.',
|
||||
scp2: 'Mở terminal trên máy tính và sử dụng lệnh SCP để tải đĩa ảo lên thư mục /data trên NanoKVM.',
|
||||
scp3: 'Ví dụ: scp đường-dẫn-image root@ip-của-nanokvm:/data',
|
||||
@@ -248,7 +274,67 @@ const vi = {
|
||||
tf3: 'Chèn thẻ TF vào đầu đọc thẻ và kết nối với máy tính của bạn.',
|
||||
tf4: 'Sao chép tệp hình ảnh vào thư mục /data trên thẻ TF.',
|
||||
tf5: 'Chèn thẻ TF vào NanoKVM.'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: 'Đang xử lý {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: 'Bật đĩa ảo?',
|
||||
desc: 'Việc gắn kết phương tiện cần đĩa ảo, hiện đang tắt. Bật ngay bây giờ? Bàn phím và chuột của máy đích sẽ ngắt kết nối trong giây lát.',
|
||||
menuHint: 'Đĩa ảo đang tắt. Hãy bật trong Cài đặt - Thiết bị.',
|
||||
askDevice: 'Đĩa ảo đang tắt. Tài khoản có quyền thiết bị cần bật nó trước.'
|
||||
},
|
||||
add: {
|
||||
title: 'Thêm phương tiện',
|
||||
upload: 'Tải lên tệp ISO hoặc IMG',
|
||||
fromUrl: 'Tải xuống từ URL',
|
||||
url: 'URL hình ảnh',
|
||||
urlRequired: 'Vui lòng nhập URL',
|
||||
onlyImages: 'Chỉ có thể gắn kết tệp .iso và .img',
|
||||
dropHint: 'Kéo tệp ISO hoặc IMG vào đây, hoặc bấm để chọn tệp',
|
||||
cdromOnlyIso: 'CD-ROM chỉ có thể chứa ảnh ISO 9660',
|
||||
uploadFailed: 'Tải lên không thành công',
|
||||
downloadFailed: 'Tải xuống không thành công',
|
||||
uploading: 'Đang tải lên {{name}}',
|
||||
downloading: 'Đang tải xuống {{name}}',
|
||||
done: '{{name}} đã sẵn sàng',
|
||||
checksumFailed: 'Ảnh đĩa không khớp với mã kiểm tra SHA-256.',
|
||||
blocked: 'Máy đích đang dùng đĩa',
|
||||
copyHint: 'Bạn cũng có thể sao chép ảnh vào phân vùng dữ liệu qua SCP, hoặc từ máy đích.'
|
||||
},
|
||||
files: {
|
||||
selectMedium: 'Chọn một phương tiện để xem các tệp bên trong.',
|
||||
reasonAttached: 'Phương tiện này đang gắn với máy đích.',
|
||||
reasonAttachedHint:
|
||||
'Hãy đẩy ra trước: máy đích phải nhả đĩa trước khi có thể đọc các tệp của nó.',
|
||||
reasonNotBrowsable: 'Phương tiện này chứa hệ thống tệp mà trình quản lý không mở được.',
|
||||
reasonNoLoop: 'Thiết bị này không gắn kết được ảnh đĩa (không có hỗ trợ loop).',
|
||||
reasonISOImage:
|
||||
'Không thể duyệt nội dung ảnh ISO 9660 tại đây. Ảnh vẫn có thể được gắn kết vào máy đích.',
|
||||
empty: 'Thư mục này trống',
|
||||
noMatch: 'Không có tệp nào khớp với tìm kiếm.',
|
||||
free: '{{size}} trống',
|
||||
search: 'Tìm trong thư mục này',
|
||||
sort: {
|
||||
name: 'Tên',
|
||||
size: 'Kích thước',
|
||||
type: 'Loại',
|
||||
asc: 'Tăng dần',
|
||||
desc: 'Giảm dần'
|
||||
},
|
||||
upload: 'Tải một tệp lên phương tiện',
|
||||
deleteFolderTitle: 'Xóa thư mục',
|
||||
deleteFolderConfirm:
|
||||
'Xóa thư mục {{name}} và toàn bộ nội dung bên trong? Không thể hoàn tác thao tác này.',
|
||||
uploading: 'Đang tải lên…',
|
||||
uploadFailed: 'Tải lên không thành công',
|
||||
download: 'Tải xuống',
|
||||
delete: 'Xóa',
|
||||
deleteTitle: 'Xóa tệp',
|
||||
deleteConfirm: 'Xóa {{name}} khỏi phương tiện?'
|
||||
},
|
||||
yes: 'Có',
|
||||
no: 'Không'
|
||||
},
|
||||
script: {
|
||||
title: 'Script',
|
||||
@@ -290,21 +376,11 @@ const vi = {
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'Trình tải xuống hình ảnh',
|
||||
input: 'Vui lòng nhập hình ảnh từ xa URL',
|
||||
ok: 'OK',
|
||||
disabled: '/data phân vùng là RO nên không tải được image',
|
||||
diskShared: 'Turn the virtual disk off first, then use the image download.',
|
||||
diskOff: 'Turn off the virtual disk',
|
||||
uploadbox: 'Thả file vào đây hoặc bấm vào để chọn',
|
||||
inputfile: 'Vui lòng nhập File hình ảnh',
|
||||
NoISO: 'Không có ISO',
|
||||
disabled:
|
||||
'Phân vùng dữ liệu ở chế độ chỉ đọc khi máy đích đang dùng: không ghi được ảnh mới, nhưng vẫn tải xuống được các tệp hiện có.',
|
||||
sha256: 'SHA-256 (tùy chọn)',
|
||||
sha256Placeholder: 'Nhập mã kiểm tra SHA-256 gồm 64 ký tự',
|
||||
invalidSHA256: 'SHA-256 phải là chuỗi thập lục phân gồm 64 ký tự',
|
||||
failed: 'Tải xuống thất bại',
|
||||
success: 'Tải xuống thành công',
|
||||
checksumFailed: 'Tải xuống thất bại: xác minh SHA-256 không thành công',
|
||||
cancel: 'Hủy',
|
||||
cancelFailed: 'Không thể hủy tải xuống'
|
||||
},
|
||||
@@ -453,16 +529,18 @@ const vi = {
|
||||
hidOnlyDesc: 'Dừng mô phỏng các thiết bị ảo, chỉ giữ lại điều khiển HID cơ bản',
|
||||
disk: 'Đĩa ảo',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
network: 'Mạng ảo',
|
||||
networkDesc: 'Gắn card mạng ảo trên máy chủ từ xa',
|
||||
reboot: 'Khởi động lại',
|
||||
rebootDesc: 'Bạn có chắc chắn muốn khởi động lại NanoKVM không?',
|
||||
okBtn: 'Có',
|
||||
cancelBtn: 'Không'
|
||||
cancelBtn: 'Không',
|
||||
diskLocked: 'Off while an image is mounted (turning it on cancels the mount)',
|
||||
diskCancelMount:
|
||||
'Enabling the virtual disk cancels the mounted image and interrupts a running image download or upload. Continue?',
|
||||
diskBreakDownload: 'Enabling the virtual disk interrupts the image download. Continue?',
|
||||
attention: 'Chú ý',
|
||||
updateFailed: 'Không cập nhật được thiết bị ảo'
|
||||
},
|
||||
network: {
|
||||
title: 'Mạng',
|
||||
@@ -602,7 +680,87 @@ const vi = {
|
||||
logoutBtn: 'Đăng xuất',
|
||||
logoutDesc: 'Bạn có chắc chắn muốn đăng xuất không?',
|
||||
okBtn: 'Có',
|
||||
cancelBtn: 'Không'
|
||||
cancelBtn: 'Không',
|
||||
role: 'Vai trò',
|
||||
roles: {
|
||||
admin: 'Quản trị viên',
|
||||
user: 'Người dùng'
|
||||
},
|
||||
users: {
|
||||
title: 'Người dùng',
|
||||
create: 'Tạo người dùng',
|
||||
enabled: 'Đã bật',
|
||||
disabled: 'Đã tắt',
|
||||
deviceOwner: 'Chủ sở hữu thiết bị',
|
||||
resetPassword: 'Đặt lại mật khẩu',
|
||||
delete: 'Xóa',
|
||||
deleteConfirm: 'Xóa người dùng này và thu hồi mọi phiên của họ?',
|
||||
created: 'Đã tạo người dùng',
|
||||
deleted: 'Đã xóa người dùng',
|
||||
passwordUpdated: 'Đã cập nhật mật khẩu',
|
||||
loadFailed: 'Không tải được người dùng',
|
||||
saveFailed: 'Không lưu được người dùng',
|
||||
deleteFailed: 'Không xóa được người dùng',
|
||||
editPermissions: 'Quyền',
|
||||
mcpLossTitle: 'Thu hồi quyền truy cập MCP',
|
||||
mcpLossDesc:
|
||||
'{{name}} sẽ mất quyền truy cập MCP. Khóa API MCP được dùng chung cho toàn thiết bị, nên bản sao vẫn hoạt động cho đến khi khóa được tạo lại. Tạo lại sẽ ngắt kết nối mọi máy khách MCP cho đến khi chúng nhận khóa mới.',
|
||||
mcpLossRotate: 'Tạo lại khóa API MCP',
|
||||
mcpLossKeep:
|
||||
'Khóa hiện tại vẫn hợp lệ, và ai có bản sao vẫn có thể điều khiển bàn phím và chuột. Bạn có thể tạo lại sau trong cài đặt MCP.',
|
||||
mcpKeyRotated: 'Đã tạo lại khóa API MCP. Máy khách MCP cần khóa mới.'
|
||||
},
|
||||
permissions: {
|
||||
title: 'Quyền',
|
||||
editTitle: 'Sửa quyền',
|
||||
save: 'Lưu',
|
||||
saved: 'Đã lưu',
|
||||
close: 'Đóng',
|
||||
groupConsole: 'Console',
|
||||
groupTarget: 'Máy đích',
|
||||
groupLibrary: 'Thư viện phương tiện',
|
||||
groupHost: 'Thiết bị này',
|
||||
groupServices: 'Dịch vụ',
|
||||
consoleView: 'Xem màn hình',
|
||||
consoleControl: 'Bàn phím và chuột',
|
||||
power: 'Nguồn và đánh thức',
|
||||
mediumAttach: 'Gắn phương tiện',
|
||||
libraryRead: 'Duyệt thư viện',
|
||||
libraryWrite: 'Quản lý thư viện',
|
||||
terminal: 'Terminal và tập lệnh',
|
||||
device: 'Cài đặt thiết bị',
|
||||
network: 'Cài đặt mạng',
|
||||
update: 'Cập nhật hệ thống',
|
||||
mcp: 'Dịch vụ MCP',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'Trợ lý AI',
|
||||
effectConsoleView:
|
||||
'Hiển thị màn hình của máy đích và đặt chế độ video, độ phân giải, tốc độ khung hình và chất lượng mà mọi người xem dùng chung.',
|
||||
effectConsoleControl: 'Gửi dữ liệu bàn phím và chuột tới máy đích.',
|
||||
effectPower:
|
||||
'Bật, tắt và khởi động lại máy đích, đồng thời quản lý danh sách thiết bị Wake-on-LAN.',
|
||||
effectMediumAttach:
|
||||
'Gắn và tháo phương tiện trên máy đích, và trao đổi tệp với máy đó qua phân vùng chia sẻ.',
|
||||
effectLibraryRead: 'Liệt kê và tải xuống các ảnh và tệp bên trong.',
|
||||
effectLibraryWrite: 'Tải lên, tạo và xóa ảnh, và thay đổi các tệp bên trong.',
|
||||
effectTerminal: 'Cung cấp shell root trên thiết bị này mà không qua phiên web.',
|
||||
effectDevice:
|
||||
'Cho phép bật SSH (cũng là root), khởi động lại thiết bị, đưa phân vùng dữ liệu thô cho máy đích và thay đổi cài đặt OLED, HDMI, mDNS, bộ nhớ và swap.',
|
||||
effectNetwork: 'Thay đổi Wi-Fi, DNS và TLS, có thể khiến thiết bị này mất kết nối mạng.',
|
||||
effectUpdate: 'Cài đặt firmware và gói ứng dụng.',
|
||||
effectMcp:
|
||||
'Cấp một khóa API áp dụng cho toàn thiết bị, điều khiển bàn phím, chuột và ảnh chụp màn hình ngoài phiên web; bản sao khóa vẫn hoạt động sau khi thu hồi quyền trừ khi khóa được tạo lại, việc này sẽ được đề xuất khi gỡ quyền.',
|
||||
effectTailscale: 'Kết nối thiết bị này vào một mạng VPN.',
|
||||
effectAi:
|
||||
'Điều khiển thiết bị qua giao diện cục bộ mà các quyền khác không giới hạn, nên tương đương toàn quyền điều khiển thiết bị.',
|
||||
fullControlWarning: 'Các quyền này tương đương toàn quyền điều khiển thiết bị:',
|
||||
emptyWarning: 'Tài khoản này sẽ không có chức năng nào và chỉ có thể đăng nhập.',
|
||||
impliedBy: 'Được {{name}} bao gồm',
|
||||
grantCount_one: '{{count}} quyền',
|
||||
grantCount: '{{count}} quyền',
|
||||
adminAll: 'Quản trị viên có mọi quyền',
|
||||
catalogFailed: 'Không tải được danh sách quyền. Hãy đóng hộp thoại và thử lại.'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -4,6 +4,7 @@ const zh = {
|
||||
desktop: '远程桌面',
|
||||
login: '登录',
|
||||
changePassword: '修改密码',
|
||||
storage: '镜像挂载/文件共享',
|
||||
terminal: '终端',
|
||||
wifi: 'Wi-Fi'
|
||||
},
|
||||
@@ -58,6 +59,16 @@ const zh = {
|
||||
verifyBtn: '验证'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: '只读',
|
||||
readOnlyTip: '当前账户没有键鼠操作权限,无法向目标机发送键盘和鼠标事件,控制台仅显示画面。'
|
||||
},
|
||||
noConsole: {
|
||||
title: '该账户没有控制台权限',
|
||||
description: '该账户被授予了其他功能。可以打开下方可用页面,或联系管理员授予控制台权限。',
|
||||
nothing: '该账户还没有任何功能权限,请联系管理员授予。'
|
||||
},
|
||||
|
||||
screen: {
|
||||
scale: '缩放',
|
||||
title: '屏幕',
|
||||
@@ -208,29 +219,42 @@ const zh = {
|
||||
disable: '关闭 HID-Only 模式'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '镜像',
|
||||
loading: '加载中',
|
||||
empty: '无镜像文件',
|
||||
mountMode: '挂载模式',
|
||||
mountFailed: '挂载失败',
|
||||
mountDesc: '在某些系统中,需要在远程主机中弹出虚拟硬盘后再挂载镜像。',
|
||||
diskOnWarn: '挂载镜像会关闭虚拟U盘,电脑将无法再访问 /data 目录。是否继续?',
|
||||
diskOffWarn:
|
||||
'虚拟U盘当前已关闭,需要先开启才能挂载镜像,电脑上的 USB 设备会短暂断开重连。是否继续?',
|
||||
diskShared: '虚拟U盘已开启,本机 /data 为只读;关闭虚拟U盘后才能删除或上传镜像。',
|
||||
unmountFailed: '卸载失败',
|
||||
unmountDesc: '在某些系统中,需要在远程主机中手动弹出后再卸载镜像。',
|
||||
refresh: '刷新镜像列表',
|
||||
attention: '注意',
|
||||
deleteConfirm: '确定要删除该镜像吗?',
|
||||
okBtn: '确定',
|
||||
cancelBtn: '取消',
|
||||
storage: {
|
||||
title: '镜像挂载/文件共享',
|
||||
empty: '还没有镜像。可以上传 ISO/IMG,也可以让被控机拷到这张盘上。',
|
||||
notAttached: '未装载任何介质',
|
||||
attached: '已装载:{{name}}',
|
||||
eject: '弹出',
|
||||
mount: '装载',
|
||||
mountMode: '装载模式',
|
||||
mountModeDisk: '磁盘模式',
|
||||
mountModeCdrom: '光盘模式',
|
||||
openManager: '打开文件管理器',
|
||||
refresh: '刷新',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除介质',
|
||||
deleteConfirm: '确定删除 {{name}}?里面的文件会一起丢失。',
|
||||
downloadImage: '下载镜像',
|
||||
typePartition: '共享文件',
|
||||
volumeLabel: '可移动磁盘',
|
||||
groups: {
|
||||
share: '文件共享',
|
||||
images: '镜像'
|
||||
},
|
||||
failed: '操作失败',
|
||||
hidOnly: 'HID-Only 模式:虚拟磁盘不可用',
|
||||
disabled: '虚拟磁盘未启用(设置 - 设备)',
|
||||
force: {
|
||||
title: '强制收回介质?',
|
||||
desc: '被控机还挂着这个介质。强制收回会让 USB 设备重新枚举,它还没写盘的数据可能丢失。',
|
||||
menuHint: '被控机正在使用该介质,请到文件管理器页面强制收回。'
|
||||
},
|
||||
tips: {
|
||||
title: '如何上传',
|
||||
usb1: '将 NanoKVM 通过 USB 连接到你的电脑;',
|
||||
usb2: '确保已经挂载了虚拟硬盘(设置 - 虚拟硬盘);',
|
||||
usb2: '在本页面以大容量存储模式挂载共享文件;',
|
||||
usb3: '在电脑上打开虚拟硬盘,将镜像文件拷贝到虚拟硬盘的根目录下。',
|
||||
usb4: '拷贝完成后弹出磁盘,镜像随后会出现在列表中。',
|
||||
scp1: '确保 NanoKVM 和你的电脑在同一个局域网内;',
|
||||
scp2: '在电脑上打开终端软件,使用 SCP 命令将镜像文件上传到 NanoKVM 的 /data 目录。',
|
||||
scp3: '示例:scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -240,7 +264,64 @@ const zh = {
|
||||
tf3: '将 TF 卡插入读卡器并连接到你的电脑;',
|
||||
tf4: '从电脑上拷贝镜像文件到 TF 卡的 /data 目录下;',
|
||||
tf5: '将 TF 卡重新插入 NanoKVM。'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '正在处理 {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: '启用虚拟磁盘?',
|
||||
desc: '装载介质需要虚拟磁盘,当前它是关闭的。现在启用?被控机的键鼠会短暂断开。',
|
||||
menuHint: '虚拟磁盘未启用,请到 设置 - 设备 打开。',
|
||||
askDevice: '虚拟磁盘当前已关闭,需要拥有“设备设置”权限的账户先将其开启。'
|
||||
},
|
||||
add: {
|
||||
title: '添加介质',
|
||||
upload: '上传 ISO / IMG',
|
||||
fromUrl: '从 URL 下载',
|
||||
url: '镜像地址',
|
||||
urlRequired: '请输入镜像地址',
|
||||
onlyImages: '只能挂载 .iso 和 .img 文件',
|
||||
dropHint: '把 ISO / IMG 拖到这里,或点击选择文件',
|
||||
cdromOnlyIso: '光盘模式只能挂载 ISO 光盘镜像',
|
||||
uploadFailed: '上传失败',
|
||||
downloadFailed: '下载失败',
|
||||
uploading: '正在上传 {{name}}',
|
||||
downloading: '正在下载 {{name}}',
|
||||
done: '{{name}} 已就绪',
|
||||
checksumFailed: '镜像与 SHA-256 校验值不符。',
|
||||
blocked: '被控机正在使用这张盘',
|
||||
copyHint: '也可以通过 SCP 把镜像放进数据分区,或在被控机上直接拷贝。'
|
||||
},
|
||||
files: {
|
||||
selectMedium: '选择一个介质,查看里面的文件。',
|
||||
reasonAttached: '该介质正装载给被控机使用。',
|
||||
reasonAttachedHint: '请先弹出:被控机必须释放这块盘,里面的文件才能读取。',
|
||||
reasonNotBrowsable: '这个介质里的文件系统无法在网页端打开。',
|
||||
reasonNoLoop: '本机不支持挂载磁盘镜像(内核未启用 loop)。',
|
||||
reasonISOImage: 'ISO 光盘镜像不支持在网页端浏览目录,镜像本身仍可正常挂载给被控机',
|
||||
empty: '这个目录是空的',
|
||||
noMatch: '没有匹配的文件',
|
||||
free: '剩余 {{size}}',
|
||||
search: '搜索当前目录',
|
||||
sort: {
|
||||
name: '名称',
|
||||
size: '大小',
|
||||
type: '类型',
|
||||
asc: '升序',
|
||||
desc: '降序'
|
||||
},
|
||||
upload: '上传文件到介质',
|
||||
deleteFolderTitle: '删除文件夹',
|
||||
deleteFolderConfirm: '删除文件夹 {{name}} 及其中的全部内容?此操作无法撤销。',
|
||||
uploading: '正在上传…',
|
||||
uploadFailed: '上传失败',
|
||||
download: '下载',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除文件',
|
||||
deleteConfirm: '确定从介质中删除 {{name}}?'
|
||||
},
|
||||
yes: '确定',
|
||||
no: '取消'
|
||||
},
|
||||
script: {
|
||||
title: '脚本',
|
||||
@@ -282,21 +363,10 @@ const zh = {
|
||||
ok: '确定'
|
||||
},
|
||||
download: {
|
||||
title: '下载镜像',
|
||||
input: '请输入远程镜像 URL',
|
||||
ok: '确定',
|
||||
disabled: '/data 是只读分区,无法下载镜像',
|
||||
diskShared: '请先关闭虚拟U盘功能,再使用镜像下载功能',
|
||||
diskOff: '关闭虚拟U盘功能',
|
||||
uploadbox: '将文件拖放到此处或单击选择',
|
||||
inputfile: '请输入镜像文件',
|
||||
NoISO: '无 ISO',
|
||||
disabled: '共享文件夹正被被控机使用,/data 当前只读:不能写入新镜像,已有文件仍可正常下载。',
|
||||
sha256: 'SHA-256(可选)',
|
||||
sha256Placeholder: '请输入 64 位 SHA-256 校验和',
|
||||
invalidSHA256: 'SHA-256 必须是 64 位十六进制字符串',
|
||||
failed: '下载失败',
|
||||
success: '下载成功',
|
||||
checksumFailed: '下载失败:SHA-256 校验失败',
|
||||
cancel: '取消',
|
||||
cancelFailed: '取消下载失败'
|
||||
},
|
||||
@@ -442,16 +512,18 @@ const zh = {
|
||||
hidOnlyDesc: '该模式下不再挂载虚拟设备,仅保留基础的 HID 控制功能。',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
diskLocked: '挂载镜像期间已关闭(开启会取消镜像挂载)',
|
||||
diskCancelMount:
|
||||
'开启虚拟U盘会取消当前挂载的镜像,并中断正在进行的镜像下载或上传,是否继续?',
|
||||
diskBreakDownload: '开启虚拟U盘会中断镜像下载功能,是否继续?',
|
||||
network: '虚拟网卡',
|
||||
networkDesc: '在远程主机中挂载虚拟网卡',
|
||||
reboot: '重新启动',
|
||||
rebootDesc: '您确定要重新启动 NanoKVM 吗?',
|
||||
okBtn: '是',
|
||||
cancelBtn: '否'
|
||||
cancelBtn: '否',
|
||||
diskLocked: '挂载镜像期间已关闭(开启会取消镜像挂载)',
|
||||
diskCancelMount:
|
||||
'开启虚拟U盘会取消当前挂载的镜像,并中断正在进行的镜像下载或上传,是否继续?',
|
||||
diskBreakDownload: '开启虚拟U盘会中断镜像下载功能,是否继续?',
|
||||
attention: '注意',
|
||||
updateFailed: '虚拟设备更新失败'
|
||||
},
|
||||
network: {
|
||||
title: '网络',
|
||||
@@ -607,7 +679,62 @@ const zh = {
|
||||
passwordUpdated: '密码已更新',
|
||||
loadFailed: '加载用户失败',
|
||||
saveFailed: '保存用户失败',
|
||||
deleteFailed: '删除用户失败'
|
||||
deleteFailed: '删除用户失败',
|
||||
editPermissions: '权限',
|
||||
mcpLossTitle: '撤销 MCP 访问',
|
||||
mcpLossDesc:
|
||||
'{{name}} 将失去 MCP 访问权限。MCP API Key 由整台设备共用,已复制出去的 Key 在重新生成前仍然有效。重新生成会断开所有 MCP 客户端,直到它们换用新 Key。',
|
||||
mcpLossRotate: '重新生成 MCP API Key',
|
||||
mcpLossKeep:
|
||||
'当前 Key 将继续有效,持有副本的人仍可控制键盘和鼠标。之后可在 MCP 设置中重新生成。',
|
||||
mcpKeyRotated: 'MCP API Key 已重新生成,MCP 客户端需换用新 Key。'
|
||||
},
|
||||
permissions: {
|
||||
title: '功能权限',
|
||||
editTitle: '编辑权限',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
close: '关闭',
|
||||
groupConsole: '控制台',
|
||||
groupTarget: '被控机',
|
||||
groupLibrary: '介质库',
|
||||
groupHost: '本设备',
|
||||
groupServices: '服务',
|
||||
consoleView: '观看画面',
|
||||
consoleControl: '键鼠操作',
|
||||
power: '电源与唤醒',
|
||||
mediumAttach: '挂载与交换介质',
|
||||
libraryRead: '浏览介质库',
|
||||
libraryWrite: '管理介质库',
|
||||
terminal: '终端与脚本',
|
||||
device: '设备设置',
|
||||
network: '网络设置',
|
||||
update: '系统更新',
|
||||
mcp: 'MCP 服务',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI 助手',
|
||||
effectConsoleView: '显示被控机画面,并设置所有观看者共用的视频模式、分辨率、帧率和画质。',
|
||||
effectConsoleControl: '向被控机发送键盘和鼠标事件。',
|
||||
effectPower: '对被控机开机、关机、复位,并管理网络唤醒设备列表。',
|
||||
effectMediumAttach: '把介质挂载到被控机或弹出,并通过共享分区与被控机交换文件。',
|
||||
effectLibraryRead: '列出并下载镜像及镜像内的文件。',
|
||||
effectLibraryWrite: '上传、新建、删除镜像,并修改镜像内的文件。',
|
||||
effectTerminal: '在本设备上获得 root shell,无需经过 Web 会话。',
|
||||
effectDevice:
|
||||
'可启用 SSH(同样是 root)、重启设备、把原始数据分区交给被控机,并修改 OLED、HDMI、mDNS、内存与交换设置。',
|
||||
effectNetwork: '修改 Wi-Fi、DNS 与 TLS,可能使本设备失联。',
|
||||
effectUpdate: '安装固件与应用更新包。',
|
||||
effectMcp:
|
||||
'下发设备级 API Key,可在 Web 会话之外控制键鼠并获取截图;撤销此权限后,已复制的 Key 仍有效,除非重新生成(移除权限时会询问是否重新生成)。',
|
||||
effectTailscale: '把本设备加入 VPN 组网。',
|
||||
effectAi: '通过不受其他权限限制的本地接口操作设备,因此相当于完整的设备控制权。',
|
||||
fullControlWarning: '下列权限等同于完整的设备控制权:',
|
||||
emptyWarning: '该账户将不具备任何功能,只能登录。',
|
||||
impliedBy: '由「{{name}}」自动包含',
|
||||
grantCount_one: '{{count}} 项权限',
|
||||
grantCount: '{{count}} 项权限',
|
||||
adminAll: '管理员拥有全部权限',
|
||||
catalogFailed: '权限列表加载失败,请关闭后重试。'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ const zh_tw = {
|
||||
login: '登入',
|
||||
changePassword: '更改密碼',
|
||||
terminal: '終端機',
|
||||
wifi: 'Wi-Fi'
|
||||
wifi: 'Wi-Fi',
|
||||
storage: '映像檔掛載 / 檔案共享'
|
||||
},
|
||||
auth: {
|
||||
login: '登入',
|
||||
@@ -37,7 +38,10 @@ const zh_tw = {
|
||||
change2: '網頁登入密碼',
|
||||
change3: 'root user 密碼 (SSH登入密碼)',
|
||||
change4: '如果您忘記密碼,需要長按 NanoKVM 上的 BOOT 按鍵來重設密碼。'
|
||||
}
|
||||
},
|
||||
placeholderCurrentPassword: '請輸入目前密碼',
|
||||
passwordLength: '密碼長度必須為 8 到 72 個字元',
|
||||
invalidCurrentPassword: '目前密碼錯誤'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
@@ -55,6 +59,16 @@ const zh_tw = {
|
||||
verifyBtn: '驗證'
|
||||
}
|
||||
},
|
||||
desktop: {
|
||||
readOnly: '唯讀',
|
||||
readOnlyTip:
|
||||
'此帳號沒有鍵盤與滑鼠操作權限,無法向目標機發送鍵盤與滑鼠事件,主控台僅顯示畫面。'
|
||||
},
|
||||
noConsole: {
|
||||
title: '此帳戶沒有控制台權限',
|
||||
description: '此帳戶被授予了其他功能。可以開啟下方可用頁面,或聯絡管理員授予控制台權限。',
|
||||
nothing: '此帳戶還沒有任何功能權限,請聯絡管理員授予。'
|
||||
},
|
||||
screen: {
|
||||
scale: '缩放',
|
||||
title: '螢幕',
|
||||
@@ -205,29 +219,42 @@ const zh_tw = {
|
||||
disable: '停用 HID-Only 模式'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '映像檔',
|
||||
loading: '載入中...',
|
||||
empty: '未找到任何內容',
|
||||
mountMode: '掛載模式',
|
||||
mountFailed: '掛載失敗',
|
||||
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
|
||||
diskOnWarn: '掛載映像檔會關閉虛擬隨身碟,電腦將無法再存取 /data 目錄。是否繼續?',
|
||||
diskOffWarn:
|
||||
'虛擬隨身碟目前已關閉,需要先開啟才能掛載映像檔,電腦上的 USB 裝置會短暫斷開重連。是否繼續?',
|
||||
diskShared: '虛擬隨身碟已開啟,本機 /data 為唯讀;關閉虛擬隨身碟後才能刪除或上傳映像檔。',
|
||||
unmountFailed: '解除安裝失敗',
|
||||
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
|
||||
refresh: '重新整理映像檔列表',
|
||||
attention: '注意',
|
||||
deleteConfirm: '確定要刪除該映像檔嗎?',
|
||||
okBtn: '確定',
|
||||
cancelBtn: '取消',
|
||||
storage: {
|
||||
title: '映像檔掛載 / 檔案共享',
|
||||
empty: '還沒有映像檔。可以上傳 ISO/IMG,也可以讓受控裝置拷到這張磁碟上。',
|
||||
notAttached: '尚未裝載任何媒體',
|
||||
attached: '已裝載:{{name}}',
|
||||
eject: '彈出',
|
||||
mount: '裝載',
|
||||
mountMode: '裝載模式',
|
||||
mountModeDisk: '磁碟模式',
|
||||
mountModeCdrom: '光碟模式',
|
||||
openManager: '開啟檔案管理員',
|
||||
refresh: '重新整理',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除媒體',
|
||||
deleteConfirm: '確定刪除 {{name}}?裡面的檔案會一起遺失。',
|
||||
downloadImage: '下載映像檔',
|
||||
typePartition: '共享檔案',
|
||||
volumeLabel: '可移除磁碟',
|
||||
groups: {
|
||||
share: '檔案共享',
|
||||
images: '映像檔'
|
||||
},
|
||||
failed: '操作失敗',
|
||||
hidOnly: 'HID-Only 模式:虛擬隨身碟無法使用',
|
||||
disabled: '虛擬隨身碟未啟用(設定 - 設備)',
|
||||
force: {
|
||||
title: '強制收回媒體?',
|
||||
desc: '受控裝置還掛著這個媒體。強制收回會讓 USB 裝置重新列舉,它還沒寫入磁碟的資料可能會遺失。',
|
||||
menuHint: '受控裝置正在使用該媒體,請到檔案管理員頁面強制收回。'
|
||||
},
|
||||
tips: {
|
||||
title: '如何上傳',
|
||||
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
|
||||
usb2: '確保已安裝虛擬磁碟(設定 - 虛擬磁碟)。',
|
||||
usb2: '在本頁面以大容量儲存模式掛載共享檔案;',
|
||||
usb3: '開啟電腦上的虛擬磁碟,將映象檔案複製到虛擬磁碟的根目錄下。',
|
||||
usb4: '複製完成後退出磁碟,映像檔隨後會出現在清單中。',
|
||||
scp1: '確保 NanoKVM 和您的電腦位於同一區域網路。',
|
||||
scp2: '開啟電腦上的終端機,使用 SCP 指令將映像檔案上傳到 NanoKVM 的 /data 目錄下。',
|
||||
scp3: '範例:scp your-image-path root@your-nanokvm-ip:/data',
|
||||
@@ -237,7 +264,64 @@ const zh_tw = {
|
||||
tf3: '使用 USB 讀卡機將 microSD 卡連接至電腦。',
|
||||
tf4: '將映像檔複製到 microSD 卡的 /data 目錄下。',
|
||||
tf5: '將 microSD 卡重新插回 NanoKVM。'
|
||||
}
|
||||
},
|
||||
job: {
|
||||
running: '正在處理 {{name}}'
|
||||
},
|
||||
enableDisk: {
|
||||
title: '啟用虛擬隨身碟?',
|
||||
desc: '裝載媒體需要虛擬隨身碟,目前它是關閉的。現在啟用?受控裝置的鍵盤與滑鼠會短暫中斷。',
|
||||
menuHint: '虛擬隨身碟未啟用,請到 設定 - 設備 開啟。',
|
||||
askDevice: '虛擬磁碟目前已關閉,需要擁有「設備設定」權限的帳號先將其開啟。'
|
||||
},
|
||||
add: {
|
||||
title: '新增媒體',
|
||||
upload: '上傳 ISO / IMG',
|
||||
fromUrl: '從 URL 下載',
|
||||
url: '映像檔網址',
|
||||
urlRequired: '請輸入映像檔網址',
|
||||
onlyImages: '只能掛載 .iso 和 .img 檔案',
|
||||
dropHint: '把 ISO / IMG 拖到這裡,或點擊選擇檔案',
|
||||
cdromOnlyIso: '光碟模式只能掛載 ISO 光碟映像檔',
|
||||
uploadFailed: '上傳失敗',
|
||||
downloadFailed: '下載失敗',
|
||||
uploading: '正在上傳 {{name}}',
|
||||
downloading: '正在下載 {{name}}',
|
||||
done: '{{name}} 已就緒',
|
||||
checksumFailed: '映像檔與 SHA-256 校驗值不符。',
|
||||
blocked: '受控裝置正在使用這張磁碟',
|
||||
copyHint: '也可以透過 SCP 把映像檔放進資料分割區,或在受控裝置上直接複製。'
|
||||
},
|
||||
files: {
|
||||
selectMedium: '選擇一個媒體,查看裡面的檔案。',
|
||||
reasonAttached: '該媒體正裝載給受控裝置使用。',
|
||||
reasonAttachedHint: '請先彈出:受控裝置必須釋放這張磁碟,裡面的檔案才能讀取。',
|
||||
reasonNotBrowsable: '這個媒體裡的檔案系統無法在網頁端開啟。',
|
||||
reasonNoLoop: '本機不支援掛載磁碟映像檔(核心未啟用 loop)。',
|
||||
reasonISOImage: 'ISO 光碟映像檔不支援在網頁端瀏覽目錄,映像檔本身仍可正常掛載給受控裝置',
|
||||
empty: '這個資料夾是空的',
|
||||
noMatch: '沒有符合的檔案',
|
||||
free: '剩餘 {{size}}',
|
||||
search: '搜尋目前資料夾',
|
||||
sort: {
|
||||
name: '名稱',
|
||||
size: '大小',
|
||||
type: '類型',
|
||||
asc: '升冪',
|
||||
desc: '降冪'
|
||||
},
|
||||
upload: '上傳檔案到媒體',
|
||||
deleteFolderTitle: '刪除資料夾',
|
||||
deleteFolderConfirm: '刪除資料夾 {{name}} 及其中的所有內容?此操作無法復原。',
|
||||
uploading: '正在上傳…',
|
||||
uploadFailed: '上傳失敗',
|
||||
download: '下載',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除檔案',
|
||||
deleteConfirm: '確定從媒體中刪除 {{name}}?'
|
||||
},
|
||||
yes: '確定',
|
||||
no: '取消'
|
||||
},
|
||||
script: {
|
||||
title: '指令碼',
|
||||
@@ -279,21 +363,11 @@ const zh_tw = {
|
||||
ok: '確定'
|
||||
},
|
||||
download: {
|
||||
title: '下载映像檔',
|
||||
input: '請輸入映像檔的下載 URL',
|
||||
ok: '確定',
|
||||
disabled: '/data 為唯讀目錄,無法下載映像檔',
|
||||
diskShared: '請先關閉虛擬隨身碟功能,再使用映像檔下載功能',
|
||||
diskOff: '關閉虛擬隨身碟功能',
|
||||
uploadbox: '將檔案拖曳到此處或按一下選擇',
|
||||
inputfile: '請輸入映像檔案',
|
||||
NoISO: '無 ISO',
|
||||
disabled:
|
||||
'共享資料夾正被受控裝置使用,/data 目前唯讀:無法寫入新映像檔,既有檔案仍可正常下載。',
|
||||
sha256: 'SHA-256(可選)',
|
||||
sha256Placeholder: '請輸入 64 位元 SHA-256 校驗和',
|
||||
invalidSHA256: 'SHA-256 必須是 64 位元十六進位字串',
|
||||
failed: '下載失敗',
|
||||
success: '下載成功',
|
||||
checksumFailed: '下載失敗:SHA-256 校驗失敗',
|
||||
cancel: '取消',
|
||||
cancelFailed: '取消下載失敗'
|
||||
},
|
||||
@@ -439,16 +513,18 @@ const zh_tw = {
|
||||
hidOnlyDesc: '停止模擬虛擬設備,僅保留基礎 HID 控制',
|
||||
disk: '虛擬隨身碟',
|
||||
diskDesc: '在遠端主機上連接虛擬隨身碟',
|
||||
diskLocked: '掛載映像檔期間已關閉(開啟會取消映像檔掛載)',
|
||||
diskCancelMount:
|
||||
'開啟虛擬隨身碟會取消目前掛載的映像檔,並中斷正在進行的映像檔下載或上傳,是否繼續?',
|
||||
diskBreakDownload: '開啟虛擬隨身碟會中斷映像檔下載功能,是否繼續?',
|
||||
network: '虛擬網卡',
|
||||
networkDesc: '在遠端主機上新增虛擬網卡',
|
||||
reboot: '重新啟動',
|
||||
rebootDesc: '您確定要重新啟動 NanoKVM?',
|
||||
okBtn: '確定',
|
||||
cancelBtn: '取消'
|
||||
cancelBtn: '取消',
|
||||
diskLocked: '掛載映像檔期間已關閉(開啟會取消映像檔掛載)',
|
||||
diskCancelMount:
|
||||
'開啟虛擬隨身碟會取消目前掛載的映像檔,並中斷正在進行的映像檔下載或上傳,是否繼續?',
|
||||
diskBreakDownload: '開啟虛擬隨身碟會中斷映像檔下載功能,是否繼續?',
|
||||
attention: '注意',
|
||||
updateFailed: '虛擬裝置更新失敗'
|
||||
},
|
||||
network: {
|
||||
title: '網路',
|
||||
@@ -584,7 +660,84 @@ const zh_tw = {
|
||||
logoutBtn: '登出',
|
||||
logoutDesc: '您確定要登出嗎?',
|
||||
okBtn: '確定',
|
||||
cancelBtn: '取消'
|
||||
cancelBtn: '取消',
|
||||
role: '角色',
|
||||
roles: {
|
||||
admin: '管理員',
|
||||
user: '一般使用者'
|
||||
},
|
||||
users: {
|
||||
title: '使用者管理',
|
||||
create: '建立使用者',
|
||||
enabled: '已啟用',
|
||||
disabled: '已停用',
|
||||
deviceOwner: '裝置擁有者',
|
||||
resetPassword: '重設密碼',
|
||||
delete: '刪除',
|
||||
deleteConfirm: '刪除此使用者並撤銷其全部工作階段?',
|
||||
created: '使用者已建立',
|
||||
deleted: '使用者已刪除',
|
||||
passwordUpdated: '密碼已更新',
|
||||
loadFailed: '載入使用者失敗',
|
||||
saveFailed: '儲存使用者失敗',
|
||||
deleteFailed: '刪除使用者失敗',
|
||||
editPermissions: '權限',
|
||||
mcpLossTitle: '撤銷 MCP 存取',
|
||||
mcpLossDesc:
|
||||
'{{name}} 將失去 MCP 存取權限。MCP API Key 由整台裝置共用,已複製出去的 Key 在重新產生前仍然有效。重新產生會中斷所有 MCP 用戶端,直到它們改用新 Key。',
|
||||
mcpLossRotate: '重新產生 MCP API Key',
|
||||
mcpLossKeep:
|
||||
'目前的 Key 將繼續有效,持有副本的人仍可控制鍵盤和滑鼠。之後可在 MCP 設定中重新產生。',
|
||||
mcpKeyRotated: 'MCP API Key 已重新產生,MCP 用戶端需改用新 Key。'
|
||||
},
|
||||
permissions: {
|
||||
title: '功能權限',
|
||||
editTitle: '編輯權限',
|
||||
save: '儲存',
|
||||
saved: '已儲存',
|
||||
close: '關閉',
|
||||
groupConsole: '控制台',
|
||||
groupTarget: '被控機',
|
||||
groupLibrary: '媒體庫',
|
||||
groupHost: '本裝置',
|
||||
groupServices: '服務',
|
||||
consoleView: '觀看畫面',
|
||||
consoleControl: '鍵鼠操作',
|
||||
power: '電源與喚醒',
|
||||
mediumAttach: '掛載與交換媒體',
|
||||
libraryRead: '瀏覽媒體庫',
|
||||
libraryWrite: '管理媒體庫',
|
||||
terminal: '終端機與腳本',
|
||||
device: '裝置設定',
|
||||
network: '網路設定',
|
||||
update: '系統更新',
|
||||
mcp: 'MCP 服務',
|
||||
tailscale: 'Tailscale',
|
||||
ai: 'AI 助手',
|
||||
effectConsoleView:
|
||||
'顯示被控機畫面,並設定所有觀看者共用的視訊模式、解析度、影格率與畫質。',
|
||||
effectConsoleControl: '向被控機發送鍵盤與滑鼠事件。',
|
||||
effectPower: '對被控機開機、關機、重新啟動,並管理網路喚醒裝置清單。',
|
||||
effectMediumAttach: '把媒體掛載到被控機或退出,並透過共享分割區與被控機交換檔案。',
|
||||
effectLibraryRead: '列出並下載映像檔及映像檔內的檔案。',
|
||||
effectLibraryWrite: '上傳、建立、刪除映像檔,並修改映像檔內的檔案。',
|
||||
effectTerminal: '在本裝置上取得 root shell,無需經過 Web 工作階段。',
|
||||
effectDevice:
|
||||
'可啟用 SSH(同樣是 root)、重新啟動裝置、把原始資料分割區交給被控機,並修改 OLED、HDMI、mDNS、記憶體與置換空間設定。',
|
||||
effectNetwork: '修改 Wi-Fi、DNS 與 TLS,可能使本裝置離線。',
|
||||
effectUpdate: '安裝韌體與應用程式更新包。',
|
||||
effectMcp:
|
||||
'下發裝置級 API Key,可在 Web 工作階段之外控制鍵鼠並取得截圖;撤銷此權限後,已複製的 Key 仍有效,除非重新產生(移除權限時會詢問是否重新產生)。',
|
||||
effectTailscale: '把本裝置加入 VPN 網路。',
|
||||
effectAi: '透過不受其他權限限制的本機介面操作裝置,因此相當於完整的裝置控制權。',
|
||||
fullControlWarning: '下列權限等同於完整的裝置控制權:',
|
||||
emptyWarning: '此帳戶將不具備任何功能,只能登入。',
|
||||
impliedBy: '由「{{name}}」自動包含',
|
||||
grantCount_one: '{{count}} 項權限',
|
||||
grantCount: '{{count}} 項權限',
|
||||
adminAll: '管理員擁有全部權限',
|
||||
catalogFailed: '權限清單載入失敗,請關閉後重試。'
|
||||
}
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
|
||||
@@ -3,3 +3,34 @@ export const AUTH_EXPIRED_EVENT = 'nano-kvm:auth-expired';
|
||||
export function notifyAuthExpired() {
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
|
||||
}
|
||||
|
||||
export const AUTH_REFRESH_EVENT = 'nano-kvm:auth-refresh';
|
||||
|
||||
const REFRESH_INTERVAL = 2000;
|
||||
|
||||
let lastRefreshNotifyAt = 0;
|
||||
let pendingRefresh: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// notifyAuthRefresh asks the app to silently refetch the current account,
|
||||
// e.g. after permissions changed. It is rate limited because it is also
|
||||
// triggered by any 403 response, but events are coalesced rather than dropped:
|
||||
// the last change still has to be applied, otherwise a grant that lands right
|
||||
// after another update would stay invisible until the next reload.
|
||||
export function notifyAuthRefresh() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRefreshNotifyAt;
|
||||
|
||||
if (elapsed < REFRESH_INTERVAL) {
|
||||
if (pendingRefresh === null) {
|
||||
pendingRefresh = setTimeout(() => {
|
||||
pendingRefresh = null;
|
||||
lastRefreshNotifyAt = Date.now();
|
||||
window.dispatchEvent(new Event(AUTH_REFRESH_EVENT));
|
||||
}, REFRESH_INTERVAL - elapsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastRefreshNotifyAt = now;
|
||||
window.dispatchEvent(new Event(AUTH_REFRESH_EVENT));
|
||||
}
|
||||
|
||||
7
web/src/lib/file-names.ts
Normal file
7
web/src/lib/file-names.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// extension is a file name's lower case extension without its dot, or an empty
|
||||
// string when the name has none. Sorting a listing by type and picking the
|
||||
// glyph for an entry are the same question, so both read the name here.
|
||||
export function extension(name: string): string {
|
||||
const index = name.lastIndexOf('.');
|
||||
return index > 0 ? name.slice(index + 1).toLowerCase() : '';
|
||||
}
|
||||
8
web/src/lib/format.ts
Normal file
8
web/src/lib/format.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// formatSize renders a byte count for transfer progress and file listings:
|
||||
// 512 B, 4.0 KB, 128.0 MB, 1.50 GB.
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user