Compare commits

...

17 Commits
2.3.4 ... 2.3.6

Author SHA1 Message Date
wenjie
590db5b08c release: nanokvm@2.3.6 2026-03-12 16:02:18 +08:00
wenjie
10a512ec96 feat: Implement AP password authentication for the WiFi configuration page 2026-03-12 15:21:18 +08:00
wenjie
08093dddad Merge pull request #769 from Voranto/Voranto-patch-1
Enable support for 640x480 resolution
2026-03-12 11:21:17 +08:00
wenjie
86a85a61b7 Merge pull request #761 from klim4-bot/main
feat : Add Korean and Japanese virtual keyboard layouts
2026-03-12 11:20:54 +08:00
wenjie
cb3a2c562e Merge pull request #744 from tkmsst/pull-request
Update Japanese translation
2026-03-12 11:18:12 +08:00
Voranto
67fe57f189 Enable support for 640x480 resolution 2026-03-05 15:52:49 +01:00
BuGu
44caee21ab Add encryption parameters for OLED distribution network 2026-03-04 17:11:42 +08:00
BuGu
5b2a1ff788 Add webpage logo generation tool and method 2026-03-04 17:09:52 +08:00
BuGu
dc5ba29bcf Add a logo file generator based on TUI 2026-03-03 19:12:59 +08:00
BuGu
89c14310c7 Add custom logo function 2026-03-03 18:50:35 +08:00
BuGu
3322a3c07f Add update mechanism for tailscale startup script 2026-03-03 18:49:02 +08:00
wj-xiao
9e1251ad6a release: nanokvm@2.3.5 2026-02-28 17:25:14 +08:00
wj-xiao
afec4efb34 feat: Implement login brute-force protection with lockout mechanism 2026-02-28 16:44:47 +08:00
wj-xiao
11980b9122 fix: improve Tailscale error handling in UI components and refine backend state mapping 2026-02-28 11:12:26 +08:00
wj-xiao
2a43fb5413 chore: update server and web dependencies 2026-02-28 10:53:31 +08:00
jason
57887f3f69 Add Korean and Japanese virtual keyboard layouts 2026-02-19 07:47:41 +09:00
tkmsst
2b8f640957 Update Japanese translation 2026-02-02 01:14:57 +09:00
38 changed files with 3977 additions and 3012 deletions

View File

@@ -1,3 +1,32 @@
## 2.3.6 (2026-03-12)
### Features
* Implemented AP password authentication for the WiFi configuration page
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
* Added encryption parameters for OLED distribution network
* Added custom logo function and logo generation tools
* Added update mechanism for tailscale startup script
### Localization
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
## 2.3.5 (2026-02-28)
### Features
* Implemented login brute-force protection with lockout mechanism
### Bug Fixes
* Improved Tailscale error handling in UI components and refined backend state mapping
### Chores
* Updated server and web dependencies
## 2.3.4 (2026-01-26)
### Features

View File

@@ -32,6 +32,18 @@ case "$1" in
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
# Logo selection
if [ -f /boot/logo.ico ]; then
if [ ! -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
fi
else
if [ -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
fi
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &

View File

@@ -26,41 +26,39 @@ server
The configuration file path is `/etc/kvm/server.yaml`.
```yaml
proto: http
# Network Settings
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
port:
http: 80
https: 443
http: 80 # The listening port for the HTTP service. Default is `80`
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # The path to the public key certificate for HTTPS
key: server.key # The path to the private key file for HTTPS
# Log level (debug/info/warn/error)
# Note: Use 'info' or 'error' in production, 'debug' only for development
# Logging Configuration
logger:
level: info
file: stdout
level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`
# Authentication setting (enable/disable)
# Note: Only disable authentication in development environment
authentication: enable
# Authentication & Security
authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
jwt:
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
secretKey: ""
# JWT token expiration time in seconds. Default: 2678400 (31 days)
refreshTokenDuration: 2678400
# Invalidate all JWT tokens when the user logs out. Default: true
revokeTokensOnLogout: true
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
security:
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
# Address for custom STUN server
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
stun: stun.l.google.com:19302
# Address and authentication for custom TURN server
# WebRTC Traversal Settings
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
turnUser: example_user # The username required for authorization to the TURN server
turnCred: example_cred # The credential/password required for authorization to the TURN server
```
## Compile & Deploy

View File

@@ -24,40 +24,39 @@ server
配置文件路径为 `/etc/kvm/server.yaml`。
```yaml
proto: http
# 网络设置
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
port:
http: 80
https: 443
http: 80 # HTTP 服务的监听端口,默认为 `80`
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # HTTPS 服务使用的公钥证书路径
key: server.key # HTTPS 服务使用的私钥文件路径
# 日志级别(debug/info/warn/error)
# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。
# 日志配置
logger:
level: info
file: stdout
level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info`
file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout`
# 鉴权设置(enable/disable)
# 注意:生产环境中请勿使用 disable。
authentication: enable
# 认证与安全
authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项!
jwt:
# jwt 密钥。设置为空则使用随机生成的64位密钥
secretKey: ""
# jwt token 过期时间(单位:秒),默认为2678400(31天)
refreshTokenDuration: 2678400
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
revokeTokensOnLogout: true
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
security:
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
# 自定义 STUN 服务器的地址
# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
stun: stun.l.google.com:19302
# WebRTC 内网穿透
stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继
turnUser: example_user # TURN 服务器授权连接时使用的用户名
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
```
## 编译部署

View File

@@ -26,6 +26,10 @@ var defaultConfig = &Config{
TurnCred: "",
},
Authentication: "enable",
Security: Security{
LoginLockoutDuration: 0,
LoginMaxFailures: 5,
},
}
func checkDefaultValue() {

View File

@@ -1,14 +1,15 @@
package config
type Config struct {
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Security Security `yaml:"security"`
Hardware Hardware `yaml:"-"`
}
@@ -40,6 +41,11 @@ type Turn struct {
TurnCred string `yaml:"turnCred"`
}
type Security struct {
LoginLockoutDuration int `yaml:"loginLockoutDuration"`
LoginMaxFailures int `yaml:"loginMaxFailures"`
}
type Hardware struct {
Version HWVersion `yaml:"-"`
GPIOReset string `yaml:"-"`

View File

@@ -8,9 +8,10 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/pion/dtls/v3 v3.0.3
github.com/pion/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
@@ -32,7 +33,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -46,7 +46,7 @@ require (
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
@@ -55,6 +55,7 @@ require (
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rs/cors v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -66,7 +67,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect

View File

@@ -74,14 +74,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
@@ -100,6 +100,8 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
@@ -140,8 +142,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -150,8 +152,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=

View File

@@ -10,7 +10,8 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
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())

View File

@@ -0,0 +1,147 @@
package auth
import (
"sync"
"time"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type loginAttempt struct {
failures int
lastFailed time.Time
lockoutEnd time.Time
}
const (
maxLoginAttemptsRecords = 3000
cleanupInterval = 6 * time.Hour
)
var (
loginAttempts = make(map[string]*loginAttempt)
loginMutex sync.Mutex
cleanupOnce sync.Once
)
// startCleanupRoutine starts a background routine to clean up memory
func startCleanupRoutine() {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
go func() {
ticker := time.NewTicker(cleanupInterval)
for range ticker.C {
loginMutex.Lock()
now := time.Now()
for ip, attempt := range loginAttempts {
// Cleanup rules: if it has been locked and the lockout time has passed,
// or (although not locked) it has been 30 minutes since the last failure,
// remove this record
if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) ||
(attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) {
delete(loginAttempts, ip)
}
}
loginMutex.Unlock()
}
}()
}
// GetClientIP gets a reliable real IP
func GetClientIP(c *gin.Context) string {
ip := c.RemoteIP()
if ip == "" {
ip = c.ClientIP()
}
return ip
}
// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules.
// Returning true means the IP/System is locked out, and an error string and error code are returned.
func CheckLoginAttempt(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
if attempt, exists := loginAttempts[clientIP]; exists {
if time.Now().Before(attempt.lockoutEnd) {
log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd)
return true, -5, "Account locked due to too many failed attempts, please try again later"
}
// If lockout has elapsed, then we reset the failures and lockoutEnd.
if !attempt.lockoutEnd.IsZero() {
attempt.failures = 0
attempt.lockoutEnd = time.Time{}
}
}
return false, 0, ""
}
// RecordLoginFailure records a failed login attempt for the given IP address.
func RecordLoginFailure(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
attempt, exists := loginAttempts[clientIP]
if !exists {
// When the record pool is full, clear the records instead of global lockout to prevent DDoS
if len(loginAttempts) >= maxLoginAttemptsRecords {
log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow")
loginAttempts = make(map[string]*loginAttempt)
}
attempt = &loginAttempt{}
loginAttempts[clientIP] = attempt
}
now := time.Now()
// Failure time window: if it has been a long time since the last failure
// (e.g., beyond the lockoutDuration window), reset the failure count
if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second {
attempt.failures = 0
}
attempt.failures++
attempt.lastFailed = now
// Reach the failure limit, lock out
if attempt.failures >= conf.Security.LoginMaxFailures {
attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second)
log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd)
}
return false, 0, ""
}
// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login.
func ClearLoginAttempt(clientIP string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
loginMutex.Lock()
defer loginMutex.Unlock()
delete(loginAttempts, clientIP)
}

View File

@@ -1,10 +1,11 @@
package auth
import (
"time"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) {
return
}
clientIP := GetClientIP(c)
if locked, code, msg := CheckLoginAttempt(clientIP); locked {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, code, msg)
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
@@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) {
if ok := CompareAccount(req.Username, req.Password); !ok {
time.Sleep(2 * time.Second)
if locked, code, msg := RecordLoginFailure(clientIP); locked {
rsp.ErrRsp(c, code, msg)
return
}
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
ClearLoginAttempt(clientIP)
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
time.Sleep(1 * time.Second)

View File

@@ -20,11 +20,13 @@ const (
)
var StateMap = map[string]proto.TailscaleState{
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"NeedsMachineAuth": proto.TailscaleNotLogin,
"InUseOtherUser": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
}
func NewService() *Service {

View File

@@ -1,6 +1,7 @@
package network
import (
"crypto/subtle"
"fmt"
"os"
"os/exec"
@@ -21,6 +22,7 @@ const (
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
return
}
// Verify AP Password
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) VerifyApLogin(c *gin.Context) {
var rsp proto.Response
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
rsp.OkRsp(c)
}
func (s *Service) ConnectWifi(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
return
}
func (s *Service) DisconnectWifi(c *gin.Context) {
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
return strings.ReplaceAll(string(ssidByte), "\n", "")
}
func getApPassword() string {
passByte, err := os.ReadFile(WiFiApPassFile)
if err != nil {
return ""
}
return strings.ReplaceAll(string(passByte), "\n", "")
}

View File

@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
{1152, 864},
{1024, 768},
{800, 600},
{640, 480},
};
uint16_t hdmi_unsupported_res_list[][2] = {

File diff suppressed because it is too large Load Diff

View File

@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
void OLED_ROW(uint8_t _EN);
void OLED_ShowKVMLogo();
void OLED_ShowLogo();
void OLED_ShowSipeedLogo();
void OLED_Showline();
void OLED_Showline_1();
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);

View File

@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
OLED_Clear();
// OLED_Revolve();
OLED_ShowKVMLogo();
OLED_ShowLogo();
OLED_ShowSipeedLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
{
OLED_Revolve();
OLED_Showline_1();
OLED_ShowSipeedLogo();
OLED_ShowLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -412,8 +412,15 @@ void show_wifi_config_ip(void)
{
OLED_Clear();
get_ip_addr(WiFi_IP);
OLED_ShowString(0, 1, "Config IP:", 8);
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
OLED_ShowString(1, 0, "Config URL", 8);
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
static char wifi_addr_with_path[30];
static char wifi_addr_with_key[30];
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
}
void show_wifi_config_QR(void)
@@ -421,8 +428,7 @@ void show_wifi_config_QR(void)
static char cmd[70];
OLED_Clear();
get_ip_addr(WiFi_IP);
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
qrencode(cmd);
}

View File

@@ -53,6 +53,7 @@ void Production_testing_patch(void)
void new_app_init(void)
{
// Update the necessary scripts
system("rm -f /boot/logo.jpeg");
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
system("rm -f /etc/init.d/S02udisk");
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
@@ -66,6 +67,11 @@ void new_app_init(void)
} else {
system("rm -f /etc/init.d/S30wifi");
}
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
}
// rmmod soph_saradc
system("rmmod soph_saradc");

View File

@@ -0,0 +1,605 @@
from PIL import Image
import numpy as np
import sys
import os
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Input, Label, Static
from textual.containers import Container, Horizontal, Vertical, Center, Middle
from textual.binding import Binding
from textual import events
from textual.message import Message
# Language dictionary for i18n support
LANGUAGES = {
'zh': {
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
'contrast_minus': '对比度-',
'contrast_plus': '对比度+',
'current_contrast': '当前对比度:{}',
'click_hint': '💡 点击图片以修改像素',
'btn_invert': '🔃 反转',
'btn_reset': '🔄 重置',
'btn_export': '💾 导出',
'btn_quit': '❌ 退出',
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
'status_loaded': '已加载:{}',
'status_load_failed': '无法加载图片:{}',
'status_contrast': '对比度:{}',
'status_inverted': '已反转所有像素',
'status_reset_image': '已重置为原始图片',
'status_reset_canvas': '已清空画布',
'status_export_error': '错误:没有可导出的数据',
'status_exported': '✅ 已导出 logo 文件',
'status_pixel_toggled': '切换像素 ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': '退出',
'binding_export': '导出',
'binding_reset': '重置',
'binding_invert': '反转',
'binding_threshold_up': '阈值 +10',
'binding_threshold_down': '阈值 -10',
'binding_threshold_up_small': '阈值 +1',
'binding_threshold_down_small': '阈值 -1',
},
'en': {
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
'app_title_editor': '✏️ NanoKVM Logo Editor',
'contrast_minus': 'Contrast-',
'contrast_plus': 'Contrast+',
'current_contrast': 'Contrast: {}',
'click_hint': '💡 Click image to toggle pixels',
'btn_invert': '🔃 Invert',
'btn_reset': '🔄 Reset',
'btn_export': '💾 Export',
'btn_quit': '❌ Quit',
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
'status_loaded': 'Loaded: {}',
'status_load_failed': 'Failed to load image: {}',
'status_contrast': 'Contrast: {}',
'status_inverted': 'All pixels inverted',
'status_reset_image': 'Reset to original image',
'status_reset_canvas': 'Canvas cleared',
'status_export_error': 'Error: No data to export',
'status_exported': '✅ Exported logo file',
'status_pixel_toggled': 'Toggled pixel ({}, {})',
'lang_select_title': '选择语言 / Select Language',
'btn_chinese': '中文 (Chinese)',
'btn_english': 'English',
'binding_quit': 'Quit',
'binding_export': 'Export',
'binding_reset': 'Reset',
'binding_invert': 'Invert',
'binding_threshold_up': 'Threshold +10',
'binding_threshold_down': 'Threshold -10',
'binding_threshold_up_small': 'Threshold +1',
'binding_threshold_down_small': 'Threshold -1',
}
}
def reverse_byte(byte):
"""Reverse the bits in a byte"""
result = 0
for i in range(8):
if (byte >> i) & 1:
result |= (1 << (7 - i))
return result
def process_image(image_path, threshold=128):
"""
Process image to 16x16 binary data
Args:
image_path: Path to the image file
threshold: Binarization threshold (0-255)
Returns:
16x16 numpy array of binary values
"""
if not os.path.exists(image_path):
return None
try:
img = Image.open(image_path).convert('L')
except Exception as e:
print(f"Error: Failed to open image - {e}")
return None
# If not square, crop the center region
if img.width != img.height:
size = min(img.width, img.height)
left = (img.width - size) // 2
top = (img.height - size) // 2
img = img.crop((left, top, left + size, top + size))
# Resize to 16x16 and binarize
img = img.resize((16, 16), Image.Resampling.LANCZOS)
binary = (np.array(img) < threshold).astype(np.uint8)
return binary
def create_blank_image():
"""Create a blank 16x16 image (all black)"""
return np.zeros((16, 16), dtype=np.uint8)
def binary_to_bytes(binary):
"""Convert 16x16 binary data to 32 bytes"""
data = []
for x in range(16):
for page in range(2):
byte = 0
start_row = page * 8
for bit in range(8):
row = start_row + bit
if row < 16 and binary[row][x] == 1:
byte |= (1 << (7 - bit))
data.append(reverse_byte(byte))
return bytes(data)
def binary_to_image(binary_data, size=64):
"""
Convert 16x16 binary data to 64x64 RGBA image
Args:
binary_data: 16x16 numpy array of binary values
size: Size to scale the image to (default 64)
Returns:
PIL Image in RGBA mode
"""
# Create 16x16 RGBA image from binary data
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
pixels = img_16.load()
for y in range(16):
for x in range(16):
if binary_data[y][x] == 1:
pixels[x, y] = (255, 255, 255, 255) # White
else:
pixels[x, y] = (0, 0, 0, 255) # Black
# Scale to 64x64 using NEAREST for pixel-perfect scaling
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
return img_64
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
"""
Convert image or binary data to 64x64 32-bit ICO file
Args:
image_path: Path to the source image (optional if binary_data is provided)
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
output_path: Output ICO file path
size: Size to scale the icon to (default 64)
"""
try:
if image_path and os.path.exists(image_path):
img = Image.open(image_path).convert('RGBA')
# If not square, crop the center region
if img.width != img.height:
min_size = min(img.width, img.height)
left = (img.width - min_size) // 2
top = (img.height - min_size) // 2
img = img.crop((left, top, left + min_size, top + min_size))
# Resize to 64x64 using LANCZOS for high quality
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
elif binary_data is not None:
# Convert binary data to image and scale
img_64 = binary_to_image(binary_data, size)
else:
print("Error: No image source provided")
return False
except Exception as e:
print(f"Error: Failed to create ICO - {e}")
return False
# Save as ICO with 32-bit color depth
img_64.save(output_path, format='ICO', sizes=[(size, size)])
return True
class PixelGrid(Static):
"""16x16 pixel grid component"""
class PixelClick(Message):
def __init__(self, x: int, y: int):
super().__init__()
self.x = x
self.y = y
DEFAULT_CSS = """
PixelGrid {
width: auto;
height: auto;
padding: 0;
background: $surface;
}
"""
def __init__(self, binary_data=None, id=None):
super().__init__(id=id)
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
self.can_focus = True
def update_data(self, binary_data):
self.binary_data = binary_data
self.refresh()
def render(self):
"""Render the pixel grid"""
lines = []
# Top border
lines.append("┌" + "─" * 32 + "┐")
for y in range(16):
line = "│"
for x in range(16):
if self.binary_data[y][x] == 1:
line += "██"
else:
line += " "
line += "│"
lines.append(line)
# Bottom border
lines.append("└" + "─" * 32 + "┘")
return "\n".join(lines)
def on_click(self, event: events.Click):
"""Handle click event"""
# Get coordinates relative to the component
# Use event.offset to get coordinates relative to the component
try:
local_x = event.offset_x
local_y = event.offset_y
except AttributeError:
# If offset attribute doesn't exist, use event coordinates
local_x = event.x if hasattr(event, 'x') else 0
local_y = event.y if hasattr(event, 'y') else 0
# Render format:
# Row 0: ┌────────────────────────────────┐ (top border)
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
# Row 17: └────────────────────────────────┘ (bottom border)
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
if local_y >= 1 and local_y <= 16:
grid_y = local_y - 1
# Check if x is within valid range (1 to 32, since there are borders on both sides)
if local_x >= 1 and local_x <= 32:
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
grid_x = (local_x - 1) // 2
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
self.post_message(self.PixelClick(grid_x, grid_y))
class LanguageSelectApp(App):
"""Language selection screen"""
CSS = """
Screen {
background: $surface;
}
#lang-container {
width: 100%;
height: 100%;
content-align: center middle;
}
#lang-title {
text-align: center;
text-style: bold;
padding: 2 0;
}
#lang-buttons {
width: auto;
height: auto;
content-align: center middle;
}
Button {
margin: 1 2;
min-width: 20;
}
"""
def __init__(self):
super().__init__()
self.selected_language = 'zh' # Default to Chinese
def compose(self) -> ComposeResult:
with Container(id="lang-container"):
yield Label("选择语言 / Select Language", id="lang-title")
with Horizontal(id="lang-buttons"):
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
yield Button("English", id="btn-english", variant="success")
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "btn-chinese":
self.selected_language = 'zh'
self.exit('zh')
elif event.button.id == "btn-english":
self.selected_language = 'en'
self.exit('en')
class LogoApp(App):
"""Logo conversion TUI application"""
CSS = """
Screen {
background: $surface;
}
#main-container {
width: 100%;
height: 100%;
padding: 1 2;
}
#title {
text-align: center;
text-style: bold;
padding: 1 0;
}
#threshold-container {
width: 100%;
height: auto;
padding: 1 2;
content-align: center middle;
}
#threshold-value {
text-align: center;
padding: 0 1;
width: 20;
}
#button-container {
width: 100%;
height: auto;
content-align: center middle;
padding: 1 0;
}
Button {
margin: 0 1;
}
#status {
text-align: center;
padding: 1 0;
text-style: italic;
}
#help-text {
text-align: center;
padding: 1 0;
color: $text-muted;
}
"""
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("d", "export", "Export"),
Binding("r", "reset", "Reset"),
Binding("i", "invert", "Invert"),
Binding("up", "threshold_up", "Threshold +10"),
Binding("down", "threshold_down", "Threshold -10"),
Binding("left", "threshold_down_small", "Threshold -1"),
Binding("right", "threshold_up_small", "Threshold +1"),
]
def __init__(self, image_path=None, language='zh'):
super().__init__()
self.image_path = image_path
self.language = language
self.lang = LANGUAGES[language]
self.original_binary = None
self.current_binary = None
self.threshold = 128
self.has_image = image_path is not None
# Update bindings with selected language
self.BINDINGS = [
Binding("q", "quit", self.lang['binding_quit']),
Binding("d", "export", self.lang['binding_export']),
Binding("r", "reset", self.lang['binding_reset']),
Binding("i", "invert", self.lang['binding_invert']),
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
]
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main-container"):
if self.has_image:
yield Label(self.lang['app_title'], id="title")
# Contrast control area: [Contrast-] Current value [Contrast+]
with Horizontal(id="threshold-container"):
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
else:
yield Label(self.lang['app_title_editor'], id="title")
yield PixelGrid(id="pixel-grid")
yield Label(self.lang['click_hint'], id="click-hint")
# Action button area: Invert | Reset | Export | Quit
with Horizontal(id="button-container"):
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
yield Label("", id="status")
if self.has_image:
yield Label(self.lang['help_text_with_image'], id="help-text")
else:
yield Label(self.lang['help_text_editor'], id="help-text")
yield Footer()
def on_mount(self):
"""When app starts"""
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
self.status = self.query_one("#status", Label)
if self.has_image:
self.load_image(self.image_path)
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_blank_canvas'])
def load_image(self, path):
"""Load image"""
self.original_binary = process_image(path, self.threshold)
if self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_loaded'].format(path))
else:
self.status.update(self.lang['status_load_failed'].format(path))
def update_threshold(self, new_value: int):
"""Update threshold"""
new_value = max(0, min(255, new_value))
if new_value != self.threshold:
self.threshold = new_value
if self.threshold_value_label:
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
if self.original_binary is not None:
self.current_binary = process_image(self.image_path, self.threshold)
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_contrast'].format(self.threshold))
def on_button_pressed(self, event: Button.Pressed):
"""Button press event"""
button_id = event.button.id
if button_id == "invert-btn":
self.action_invert()
elif button_id == "reset-btn":
self.action_reset()
elif button_id == "export-btn":
self.action_export()
elif button_id == "quit-btn":
self.action_quit()
elif button_id == "threshold-plus-btn":
self.action_threshold_up()
elif button_id == "threshold-minus-btn":
self.action_threshold_down()
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
"""Pixel click event"""
if self.current_binary is not None:
# Toggle pixel value
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
def action_invert(self):
"""Invert all pixels"""
if self.current_binary is not None:
self.current_binary = 1 - self.current_binary
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_inverted'])
def action_reset(self):
"""Reset to original image or clear"""
if self.has_image and self.original_binary is not None:
self.current_binary = self.original_binary.copy()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_image'])
else:
self.current_binary = create_blank_image()
self.pixel_grid.update_data(self.current_binary)
self.status.update(self.lang['status_reset_canvas'])
def action_export(self):
"""Export logo.bin and logo.ico"""
if self.current_binary is None:
self.status.update(self.lang['status_export_error'])
return
data = binary_to_bytes(self.current_binary)
with open("logo.bin", "wb") as f:
f.write(data)
# Export 64x64 32-bit ICO file
if self.has_image and self.image_path:
image_to_ico(self.image_path, "logo.ico", size=64)
else:
# Export from current canvas (16x16 -> 64x64)
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
self.status.update(self.lang['status_exported'])
def action_threshold_up(self):
"""Increase threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold + 10)
def action_threshold_down(self):
"""Decrease threshold by 10"""
if self.has_image:
self.update_threshold(self.threshold - 10)
def action_threshold_up_small(self):
"""Increase threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold + 1)
def action_threshold_down_small(self):
"""Decrease threshold by 1"""
if self.has_image:
self.update_threshold(self.threshold - 1)
def action_quit(self):
self.exit()
def main():
image_path = None
# First, show language selection screen
lang_app = LanguageSelectApp()
selected_language = lang_app.run()
if len(sys.argv) >= 2:
image_path = sys.argv[1]
if not os.path.exists(image_path):
print(f"Error: File not found - {image_path}")
return
app = LogoApp(image_path, language=selected_language)
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
# NanoKVM Logo Generator
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
🛠️ **Tool Description:**
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
📋 **Instructions:**
1. **Install required libraries:**
```bash
pip install Pillow numpy textual
```
2. **Prepare your logo image:**
Make sure it's as square as possible for best results 🖼️
3. **Run the script:**
```bash
python logo_generator.py path/to/your_logo.png
```
4. **Choose your language:**
Select either English or Chinese
5. **Adjust the contrast:**
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
6. **Fine-tune pixel by pixel:**
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
7. **Export and deploy:**
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
* Enabling SSH and using SCP to copy the file 💻
* Or directly placing it in the boot partition of your TF card 💾
8. **Reboot and enjoy:**
Make sure your NanoKVM application version is greater than 2.3.6 ✅
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉

View File

@@ -8,6 +8,7 @@
"mocked": "vite --mode mocked",
"build": "tsc && vite build",
"lint": "eslint . --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
"preview": "vite preview"
},
"dependencies": {
@@ -16,54 +17,54 @@
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.29.1",
"axios": "1.12.0",
"antd": "^5.29.3",
"axios": "1.13.6",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.16.4",
"jotai": "^2.10.1",
"i18next": "^23.16.8",
"jotai": "^2.18.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-draggable": "^4.5.0",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.3",
"react-responsive": "^10.0.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^6.30.3",
"react-simple-keyboard": "^3.8.19",
"semver": "^7.6.3",
"react-simple-keyboard": "^3.8.164",
"semver": "^7.7.4",
"vaul": "^0.9.9",
"websocket": "^1.0.35",
"yocto-queue": "^1.2.1"
"yocto-queue": "^1.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@eslint/js": "^9.39.3",
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/semver": "^7.5.8",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@types/semver": "^7.7.1",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^8.48.0",
"@typescript-eslint/parser": "^8.48.0",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^17.0.0",
"msw": "^2.6.0",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.3",
"typescript-eslint": "^8.53.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.3.0",
"msw": "^2.12.10",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "7.1.11",
"vite-tsconfig-paths": "^4.3.2"
},
@@ -72,4 +73,4 @@
"public"
]
}
}
}

3740
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,12 +31,29 @@ export function getWiFi() {
}
// connect wifi without auth (only available in wifi configuration mode)
export function connectWifiNoAuth(ssid: string, password: string) {
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
const data = {
ssid,
password
};
return http.post('/api/network/wifi', data);
return http.post('/api/network/wifi', data, {
headers: {
'X-AP-Key': apPassword || ''
}
});
}
// verify ap login
export function verifyApLogin(apPassword: string) {
return http.post(
'/api/network/wifi/verify',
{},
{
headers: {
'X-AP-Key': apPassword || ''
}
}
);
}
// connect wifi

View File

@@ -16,6 +16,8 @@ const en = {
noEmptyPassword: 'Password required',
noAccount: 'Failed to get user information, please refresh web page or reset password',
invalidUser: 'Invalid username or password',
locked: 'Too many logins, please try again later',
globalLocked: 'System under protection, please try again later',
error: 'Unexpected error',
changePassword: 'Change Password',
changePasswordDesc: 'For the security of your device, please change the password!',
@@ -46,7 +48,14 @@ const en = {
invalidMode:
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
finishBtn: 'Finished',
ap: {
authTitle: 'Authentication Required',
authDescription: 'Please enter the AP password to continue',
authFailed: 'Invalid AP password',
passPlaceholder: 'AP password',
verifyBtn: 'Verify',
}
},
screen: {
scale: 'Scale',

View File

@@ -11,16 +11,14 @@ const ja = {
login: 'ログイン',
placeholderUsername: 'ユーザー名を入力してください',
placeholderPassword: 'パスワードを入力してください',
placeholderPassword2: '再度パスワードを入力してください',
placeholderPassword2: 'パスワードをもう一度入力してください',
noEmptyUsername: 'ユーザー名は空にできません',
noEmptyPassword: 'パスワードは空にできません',
noAccount:
'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
invalidUser: '無効なユーザー名またはパスワード',
error: '予期しないエラー',
noAccount: 'ユーザー情報の取得に失敗しました。ページを更新してもう一度お試しいただくか、パスワードをリセットしてください。',
invalidUser: 'ユーザー名またはパスワードが正しくありません',
error: '不明なエラー',
changePassword: 'パスワード変更',
changePasswordDesc:
'デバイスのセキュリティのために、ウェブログインのパスワードを変更してください。',
changePasswordDesc: 'デバイスのセキュリティのために、パスワードを変更してください!',
differentPassword: 'パスワードが一致しません',
illegalUsername: 'ユーザー名に不正な文字が含まれています',
illegalPassword: 'パスワードに不正な文字が含まれています',
@@ -29,86 +27,147 @@ const ja = {
cancel: 'キャンセル',
loginButtonText: 'ログイン',
tips: {
reset1:
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
reset2: 'For detailed steps, please consult this document:',
reset3: 'Web default account:',
reset4: 'SSH default account:',
change1: 'Please note that this action will change the following passwords:',
change2: 'Web login password',
change3: 'System root password (SSH login password)',
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
reset1: 'パスワードをリセットするには、NanoKVM の BOOT ボタンを 10 秒間押し続けます。',
reset2: '詳細な手順については、次のドキュメントを参照してください:',
reset3: 'ウェブデフォルトアカウント:',
reset4: 'SSH デフォルトアカウント:',
change1: 'この操作により、以下のパスワードも更新されることに注意してください:',
change2: 'ウェブログインパスワード',
change3: 'システム root パスワード(SSH ログインパスワード)',
change4: 'パスワードを忘れた場合は、NanoKVM の BOOT ボタンを長押ししてパスワードをリセットする必要があります。'
}
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi for NanoKVM',
success: 'Please check the network status of NanoKVM and visit the new IP address.',
failed: 'Operation failed, please try again.',
confirmBtn: 'Ok',
finishBtn: 'Finished'
description: 'NanoKVM の Wi-Fi を設定する',
success: 'NanoKVM のネットワークステータスを確認するにはデバイスにアクセスしてください。',
failed: '操作に失敗しました。もう一度お試しください。',
invalidMode: '現在のモードではネットワーク設定はサポートされていません。デバイスで Wi-Fi 設定モードを有効にしてください。',
confirmBtn: 'OK',
finishBtn: '完了'
},
screen: {
scale: '倍率',
title: '画面',
video: 'ビデオモード',
videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください',
resolution: '解像度',
auto: '自動',
autoTips:
'特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。',
fps: 'FPS',
'特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。',
fps: 'フレームレート',
customizeFps: 'カスタマイズ',
quality: '品質',
quality: '画質',
qualityLossless: 'ロスレス',
qualityHigh: '高い',
qualityMedium: '中くらい',
qualityLow: '低い',
frameDetect: 'フレーム検出',
frameDetectTip:
'フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。',
resetHdmi: 'Reset HDMI'
qualityHigh: '高',
qualityMedium: '中',
qualityLow: '低',
frameDetect: 'フレーム差分検出',
frameDetectTip: 'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
resetHdmi: 'HDMI をリセット'
},
keyboard: {
title: 'キーボード',
paste: '貼り付け',
tips: '標準的なキーボードの文字と記号のみがサポートされています',
tips: '標準的なキーボードの文字と記号のみをサポートしています',
placeholder: '入力してください',
submit: '送信',
virtual: 'キーボード',
ctrlaltdel: 'Ctrl+Alt+Del'
virtual: '仮想キーボード',
shortcut: {
title: 'ショートカット',
custom: 'カスタム',
capture: 'ショートカットをキャプチャするにはここをクリックしてください',
clear: 'クリア',
save: '保存',
captureTips: 'システムレベルのキー(Windows キーなど)をキャプチャするには、全画面表示の権限が必要です。',
enterFullScreen: '全画面モードに切り替えます。'
},
leaderKey: {
title: 'リーダーキー',
desc: 'ブラウザの制限を回避して、システムによってブロックされているショートカットキーをリモートホストに送信します。',
howToUse: '使用方法',
simultaneous: {
title: '同時モード',
desc1: 'リーダーキーを押したまま、ショートカットキーを押します。',
desc2: '操作は直感的ですが、システムの使用状況により一部のショートカットキーが機能しない場合があります。'
},
sequential: {
title: 'シーケンシャルモード',
desc1: '【リーダーキー】をクリックして開始 →【ショートカットキー】を順にクリック →【リーダーキー】をクリックして終了します。',
desc2: 'いくつかの手順が必要ですが、システムキーの競合を完全に回避します。'
},
enable: 'リーダーキーを有効化',
tip: 'リーダーキーとして設定すると、このキーはショートカットキーをトリガーするためにのみ使用され、通常のキーとしては使用できなくなります。',
placeholder: 'リーダーキーを押してください',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '送信',
recorder: {
rec: 'REC',
activate: 'キーを有効にする',
input: 'ショートカットキーを押してください...'
}
}
},
mouse: {
default: 'デフォルトカーソル',
title: 'マウス',
cursor: 'ポインター形状',
default: 'デフォルトポインター',
pointer: 'ポインターカーソル',
cell: 'セルカーソル',
cell: 'セルポインター',
text: 'テキストカーソル',
grab: 'つかむカーソル',
hide: 'カーソルを非表示',
grab: 'つかむポインター',
hide: 'ポインターを非表示',
mode: 'マウスモード',
absolute: '絶対モード',
relative: '相対モード',
requestPointer:
'相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
resetHid: 'HIDをリセット'
direction: 'ホイール方向',
scrollUp: '上',
scrollDown: '下',
speed: 'ホイール速度',
fast: '速い',
slow: '遅い',
requestPointer: '相対モードを使用中です。マウスポインターを取得するには、デスクトップをクリックしてください。',
resetHid: 'HID をリセット',
hidOnly: {
title: 'HID-Only モード',
desc: '使用中にマウスとキーボードが反応しなくなり、HID をリセットしても効果がない場合は、NanoKVM とデバイス間の互換性に問題がある可能性があります。互換性を向上させるために、HID-Only モードを有効にすることをお勧めします。',
tip1: 'このモードを有効にすると仮想 USB ドライブと仮想ネットワークアダプターが無効になります。',
tip2: 'このモードではイメージマウント機能は使用できません。',
tip3: 'モードを切り替えると、NanoKVM は自動的に再起動します。',
enable: 'HID-Only モードを有効化',
disable: 'HID-Only モードを無効化'
}
},
image: {
title: 'イメージ',
loading: '読み込み中...',
empty: '見つかりませんでした',
loading: '読み込み中',
empty: 'イメージファイルがありません',
mountMode: 'マウントモード',
mountFailed: 'マウントに失敗しました',
mountDesc:
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
mountDesc: '一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
unmountFailed: 'アンマウントに失敗しました',
unmountDesc: '一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
refresh: 'イメージリストを更新',
attention: '注意',
deleteConfirm: 'このイメージを削除してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ',
tips: {
title: 'アップロード方法',
usb1: 'NanoKVMをUSB経由でコンピュータに接続します。',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク)。',
usb1: 'NanoKVM を USB 経由でコンピュータに接続します;',
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク);',
usb3: 'コンピュータ上で仮想ディスクを開き、イメージファイルを仮想ディスクのルートディレクトリにコピーします。',
scp1: 'NanoKVMとコンピュータが同じローカルネットワークに接続されていることを確認します。',
scp2: 'コンピュータのターミナルを開き、SCPコマンドを使用してイメージファイルをNanoKVMの/dataディレクトリにアップロードします。',
scp3: '例: scp あなたのイメージパス root@あなたのナノKVMのIP:/data',
tfCard: 'TFカード',
tf1: 'この方法はLinuxシステムでサポートされています',
tf2: 'NanoKVMからTFカードを取り出します(フルバージョンの場合、まずケースを分解してください)。',
tf3: 'TFカードをカードリーダーに挿入し、コンピュータに接続します。',
tf4: 'TFカードの/dataディレクトリにイメージファイルをコピーします。',
tf5: 'TFカードをNanoKVMに挿入します。'
scp1: 'NanoKVM とコンピュータが同じローカルエリアネットワークに接続されていることを確認します;',
scp2: 'コンピュータでターミナルを開き、SCP コマンドを使用してイメージファイルを NanoKVM の /data ディレクトリにアップロードします。',
scp3: '例:scp your-image-path root@your-nanokvm-ip:/data',
tfCard: 'TF カード',
tf1: 'この方法は Linux システムでサポートされています',
tf2: 'NanoKVM から TF カードを取り出します(フルバージョンでは、まずケースを分解してください);',
tf3: 'TF カードをカードリーダーに挿入してコンピュータに接続します;',
tf4: 'コンピューターから TF カードの /data ディレクトリにイメージファイルをコピーします;',
tf5: 'TF カードを NanoKVM に挿入します。'
}
},
script: {
@@ -126,7 +185,7 @@ const ja = {
},
terminal: {
title: 'ターミナル',
nanokvm: 'NanoKVMターミナル',
nanokvm: 'NanoKVM ターミナル',
serial: 'シリアルポートターミナル',
serialPort: 'シリアルポート',
serialPortPlaceholder: 'シリアルポートを入力してください',
@@ -137,115 +196,225 @@ const ja = {
title: 'Wake-on-LAN',
sending: 'コマンドを送信中...',
sent: 'コマンドを送信しました',
input: 'MACを入力してください',
input: 'MAC アドレスを入力してください',
ok: 'OK'
},
download: {
title: 'イメージダウンローダー',
input: 'イメージのダウンロードアドレスを入力してください',
ok: 'OK',
disabled: '/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません'
},
power: {
title: '電源',
showConfirm: '確認メッセージ',
showConfirmTip: '電源操作の確認メッセージを表示する',
reset: 'リセット',
power: '電源',
powerShort: '電源(短いクリック)',
powerLong: '電源(長いクリック)'
powerShort: '電源(クリック)',
powerLong: '電源(長押し)',
resetConfirm: '再起動を実行しますか?',
powerConfirm: '電源操作を実行しますか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
settings: {
title: 'Settings',
title: '設定',
about: {
title: 'NanoKVMについて',
title: 'NanoKVM について',
information: '情報',
ip: 'IP',
mdns: 'mDNS',
application: 'アプリケーションバージョン',
applicationTip: 'NanoKVM web application version',
applicationTip: 'NanoKVM ウェブアプリケーションバージョン',
image: 'イメージバージョン',
imageTip: 'NanoKVM system image version',
firmware: 'ファームウェアバージョン',
imageTip: 'NanoKVM システムイメージバージョン',
deviceKey: 'デバイスキー',
community: 'コミュニティ'
community: 'コミュニティ',
hostname: 'ホスト名',
hostnameUpdated: 'ホスト名は正常に変更され、再起動後に有効になります'
},
appearance: {
title: 'Appearance',
display: 'Display',
language: 'Language',
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar'
title: '外観',
display: '表示',
language: '言語',
languageDesc: 'インターフェース言語の選択',
webTitle: 'ウェブページタイトル',
webTitleDesc: 'ウェブページタイトルのカスタマイズ',
menuBar: {
title: 'メニューバー',
mode: '表示モード',
modeDesc: 'メニューバーの画面表示方法',
modeOff: '閉じる',
modeAuto: '自動非表示',
modeAlways: '常に表示',
icons: 'メニューアイコン',
iconsDesc: 'メニューバーでのサブメニューアイコンの表示'
}
},
device: {
title: 'Device',
title: 'デバイス',
oled: {
title: 'OLED',
description: 'OLED screen automatically sleep',
0: 'Never',
15: '15 sec',
30: '30 sec',
60: '1 min',
180: '3 min',
300: '5 min',
600: '10 min',
1800: '30 min',
3600: '1 hour'
description: 'OLED 画面の自動スリープ時間',
0: '無効',
15: '15秒',
30: '30秒',
60: '1分',
180: '3分',
300: '5分',
600: '10分',
1800: '30分',
3600: '1時間'
},
wifi: {
title: 'Wi-Fi',
description: 'Configure Wi-Fi',
setBtn: 'Config'
description: 'Wi-Fi 設定',
apMode: 'AP モードが有効になりました。QR コードをスキャンして Wi-Fi に接続してください。',
connect: 'Wi-Fi に接続',
connectDesc1: 'SSID とパスワードを入力してください',
connectDesc2: 'このネットワークに接続するためのパスワードを入力してください',
disconnect: 'このネットワーク接続を切断しますか?',
failed: '接続に失敗しました。もう一度お試しください。',
ssid: 'SSID',
password: 'パスワード',
joinBtn: '接続',
confirmBtn: 'OK',
cancelBtn: 'キャンセル'
},
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
ssh: {
description: 'SSH リモートアクセスを有効にする',
tip: '使用する前に必ず強力なパスワードを設定してください(アカウント - パスワードの変更)'
},
tls: {
description: 'HTTPS プロトコルを有効にする',
tip: '注意:HTTPS を使用すると、特に MJPEG ビデオモードで遅延が増加する可能性があります。'
},
advanced: '詳細設定',
swap: {
disable: '無効',
description: 'スワップファイルのサイズを設定する',
tip: 'この機能を有効にすると、SD カードの寿命が短くなる可能性があります!'
},
mouseJiggler: {
title: 'マウスジグラー',
description: 'リモートホストの休止を防ぐ',
disable: '閉じる',
absolute: '絶対モード',
relative: '相対モード'
},
mdns: {
description: 'mDNS 検出サービスを有効にする',
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
},
hdmi: {
description: 'HDMI/モニター 出力機能を有効にする'
},
autostart: {
title: '自動起動スクリプト設定',
description: 'NanoKVM の起動時に自動的に実行されるスクリプトファイルを管理します',
new: '新しいスクリプトを作成する',
deleteConfirm: 'このファイルを削除してもよろしいですか?',
yes: 'はい',
no: 'いいえ',
scriptName: '自動起動スクリプト名',
scriptContent: '自動起動スクリプト内容',
settings: '設定'
},
hidOnly: 'HID-Only モード',
hidOnlyDesc: 'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
disk: '仮想ディスク',
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
network: '仮想ネットワークカード',
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
tailscale: {
title: 'Tailscale',
memory: {
title: 'Memory optimization',
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
disable: 'Disable'
title: 'メモリ最適化',
tip: "メモリ使用量が上限を超えると、メモリ解放のためにより積極的にガベージコレクションが実行されます。Tailscale を使用する場合は 50MB に設定することをお勧めします。この設定を有効にするには Tailscale を再起動する必要があります。",
},
restart: 'Are you sure to restart Tailscale?',
stop: 'Are you sure to stop Tailscale?',
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
swap: {
title: 'スワップメモリ',
tip: 'メモリ最適化を有効にしても問題が解決しない場合は、スワップメモリ​​を有効にしてみてください。有効にするとスワップファイルが 256MB に設定されます。このサイズは「設定 - デバイス」で変更できます。'
},
restart: 'Tailscale を再起動しますか?',
stop: 'Tailscale を停止しますか?',
stopDesc: 'Tailscale からログアウトし、起動時の自動実行を無効にします。',
loading: '読み込み中...',
notInstall: 'Tailscaleが見つかりません!インストールしてください。',
notInstall: 'Tailscale が見つかりません。インストールしてください。',
install: 'インストール',
installing: 'インストール中',
failed: 'インストールに失敗しました',
retry: 'ページをリフレッシュして再試行してください。または手動でインストールしてください',
download: 'インストールパッケージをダウンロードして',
package: '解凍してください',
upTailscale: 'tailscaleをNanoKVMのディレクトリ/usr/bin/にアップロードしてください',
upTailscaled: 'tailscaledをNanoKVMのディレクトリ/usr/sbin/にアップロードしてください',
refresh: '現在のページをリフレッシュします',
notLogin:
'デバイスはまだバインドされていません。ログインしてこのデバイスをアカウントにバインドしてください。',
urlPeriod: 'このURLは10分間有効です',
retry: 'ページを更新してもう一度お試しいただくか、手動でインストールしてください',
download: 'ダウンロードして',
package: 'インストールパッケージを',
unzip: '解凍してください',
upTailscale: 'tailscale ファイルを NanoKVM の /usr/bin ディレクトリにアップロードします',
upTailscaled: 'tailscaled ファイルを NanoKVM の /usr/sbin ディレクトリにアップロードします',
refresh: 'ページを更新します',
notRunning: 'Tailscale はまだ実行されていません。起動操作を実行してください',
run: '起動',
notLogin: 'このデバイスはまだバインドされていません。ログインしてデバイスをアカウントにバインドしてください。',
urlPeriod: 'この URL は 10 分間有効です',
login: 'ログイン',
loginSuccess: 'ログイン成功',
enable: 'Tailscaleを有効化',
enable: 'Tailscale を有効化',
deviceName: 'デバイス名',
deviceIP: 'デバイスIP',
deviceIP: 'デバイスアドレス',
account: 'アカウント',
logout: 'ログアウト',
logout2: 'ログアウトしてもよろしいですか?',
okBtn: 'Yes',
cancelBtn: 'No'
logoutDesc: 'ログアウトしてもよろしいですか?',
uninstall: 'Tailscale をアンインストール',
uninstallDesc: 'Tailscale をアンインストールしてもよろしいですか?',
reboot: '再起動',
rebootDesc: 'NanoKVM を再起動してもよろしいですか?',
okBtn: 'はい',
cancelBtn: 'いいえ'
},
update: {
title: 'アップデートの確認',
queryFailed: 'バージョンの取得に失敗しました',
updateFailed: '更新に失敗しました。再試行してください。',
isLatest: '最新のバージョンを既に持っています。',
available: 'アップデートが利用可能です。更新してもよろしいですか?',
updating: '更新を開始しました。お待ちください...',
confirm: '確認',
cancel: 'キャンセル'
title: 'アップデート',
queryFailed: 'バージョン番号の取得に失敗しました',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。',
isLatest: 'すでに最新バージョンです。',
available: '新しいバージョンが利用可能です。アップデートしてもよろしいですか?',
updating: 'アップデート中、お待ちください...',
confirm: 'はい',
cancel: 'いいえ',
preview: 'プレビューアップデート',
previewDesc: '新機能や改善をいち早く体験する',
previewTip: 'プレビューアップデートには不安定な部分や不完全な機能が含まれる場合があります!',
offline: {
title: 'オフラインアップデート',
desc: 'ローカルインストールパッケージでアップデートする',
upload: 'アップロード',
invalidName: 'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。',
updateFailed: 'アップデートに失敗しました。もう一度お試しください。'
}
},
account: {
title: 'Account',
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Update',
logoutBtn: 'Logout'
title: 'アカウント',
webAccount: 'ウェブアカウント名',
password: 'パスワード',
updateBtn: '変更',
logoutBtn: 'ログアウト',
logoutDesc: 'ログアウトしてもよろしいですか?',
confirm: 'はい',
cancel: 'いいえ'
}
},
error: {
title: 'エラーが発生しました',
refresh: '更新'
},
fullscreen: {
toggle: '全画面表示切り替え'
},
menu: {
collapse: 'メニューを折りたたむ',
expand: 'メニューを展開する'
}
}
};

View File

@@ -16,6 +16,8 @@ const zh = {
noEmptyPassword: '密码不能为空',
noAccount: '获取用户信息失败,请刷新重试或重置密码',
invalidUser: '用户名或密码错误',
locked: '登录太频繁,请稍后再试',
globalLocked: '系统防爆破保护中,请稍后再试',
error: '未知错误',
changePassword: '修改密码',
changePasswordDesc: '为了您的设备安全,请修改密码!',
@@ -44,7 +46,14 @@ const zh = {
failed: '操作失败,请重试。',
invalidMode: '当前模式不支持配置网络。请先前往设备启用 Wi-Fi 配置模式。',
confirmBtn: '确定',
finishBtn: '完成'
finishBtn: '完成',
ap: {
authTitle: '身份验证',
authDescription: '请输入 AP 密码以继续',
authFailed: '密码错误',
passPlaceholder: 'AP 密码',
verifyBtn: '验证',
}
},
screen: {
scale: '缩放',

View File

@@ -58,11 +58,12 @@ class Http {
});
}
public post(url: string, data?: any): Promise<Response> {
public post(url: string, data?: any, config?: AxiosRequestConfig): Promise<Response> {
return this.instance.request({
method: 'post',
url,
data
data,
...config
});
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, ReactElement } from 'react';
import { ReactElement, useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
@@ -41,7 +41,12 @@ export const Login = (): ReactElement => {
.login(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
setMsg(rsp.code === -2 ? t('auth.invalidUser') : t('auth.error'));
let errorMsg = t('auth.error');
if (rsp.code === -2) errorMsg = t('auth.invalidUser');
else if (rsp.code === -5) errorMsg = t('auth.locked');
else if (rsp.code === -4) errorMsg = t('auth.globalLocked');
setMsg(errorMsg);
return;
}
@@ -80,7 +85,8 @@ export const Login = (): ReactElement => {
setTimeout(() => {
(evt.target as HTMLImageElement).classList.remove('animate-spin');
}, 1000);
}} />
}}
/>
</div>
<Form.Item
name="username"
@@ -100,7 +106,7 @@ export const Login = (): ReactElement => {
/>
</Form.Item>
<div className="text-red-500">{msg}</div>
<div className="pb-1 text-red-500">{msg}</div>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full" loading={isLoading}>

View File

@@ -55,6 +55,9 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
onLogout();
})
.catch((err) => {
setErrMsg(err?.message || 'Failed to logout');
})
.finally(() => {
setIsLogging(false);
});

View File

@@ -41,6 +41,9 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
setStatus(rsp.data);
})
.catch((err) => {
setErrMsg(err?.message || 'Failed to get status');
})
.finally(() => {
setIsLoading(false);
});

View File

@@ -29,9 +29,12 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
}
onSuccess();
setState('');
})
.catch(() => {
setState('failed');
})
.finally(() => {
setState('');
setIsLocked(false);
});
}

View File

@@ -38,6 +38,9 @@ export const Login = ({ onSuccess }: LoginProps) => {
window.open(url, '_blank');
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
})
.catch((err) => {
setErrMsg(err?.message || 'Failed to login');
})
.finally(() => {
setIsLoading(false);
});

View File

@@ -29,6 +29,9 @@ export const Run = ({ onSuccess }: RunProps) => {
onSuccess();
})
.catch((err) => {
setErrMsg(err?.message || 'Failed to run tailscale');
})
.finally(() => {
setIsLoading(false);
});

View File

@@ -45,6 +45,7 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => {
<Modal
title={title}
open={isModalOpen}
centered={true}
okType="danger"
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}

View File

@@ -46,8 +46,10 @@ export const VirtualKeyboard = () => {
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ru', label: 'Russian' }
{ value: 'de', label: 'German' },
{ value: 'ru', label: 'Russian' },
{ value: 'ko', label: 'Korean' },
{ value: 'ja', label: 'Japanese' }
];
useEffect(() => {
@@ -67,7 +69,9 @@ export const VirtualKeyboard = () => {
['en', 'default'],
['ru', 'rus'],
['de', 'qwertz'],
['fr', 'azerty']
['fr', 'azerty'],
['ko', 'ko'],
['ja', 'ja']
]);
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
@@ -141,6 +145,16 @@ export const VirtualKeyboard = () => {
return getKeycode(base);
}
if (keyboardLanguage === 'ko' && key.endsWith('_ko')) {
const base = key.replace('_ko', '');
return getKeycode(base);
}
if (keyboardLanguage === 'ja' && key.endsWith('_ja')) {
const base = key.replace('_ja', '');
return getKeycode(base);
}
const specialKey = specialKeyMap.get(key);
if (specialKey) {
return getKeycode(specialKey);

View File

@@ -44,6 +44,22 @@ export const keyboardOptions = {
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
ko: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_ko Digit1_ko Digit2_ko Digit3_ko Digit4_ko Digit5_ko Digit6_ko Digit7_ko Digit8_ko Digit9_ko Digit0_ko Minus_ko Equal_ko {backspace}',
'{tab} KeyQ_ko KeyW_ko KeyE_ko KeyR_ko KeyT_ko KeyY_ko KeyU_ko KeyI_ko KeyO_ko KeyP_ko BracketLeft_ko BracketRight_ko Backslash_ko',
'{capslock} KeyA_ko KeyS_ko KeyD_ko KeyF_ko KeyG_ko KeyH_ko KeyJ_ko KeyK_ko KeyL_ko Semicolon_ko Quote_ko {enter}',
'{shiftleft} KeyZ_ko KeyX_ko KeyC_ko KeyV_ko KeyB_ko KeyN_ko KeyM_ko Comma_ko Period_ko Slash_ko {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
ja: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_ja Digit1_ja Digit2_ja Digit3_ja Digit4_ja Digit5_ja Digit6_ja Digit7_ja Digit8_ja Digit9_ja Digit0_ja Minus_ja Equal_ja {backspace}',
'{tab} KeyQ_ja KeyW_ja KeyE_ja KeyR_ja KeyT_ja KeyY_ja KeyU_ja KeyI_ja KeyO_ja KeyP_ja BracketLeft_ja BracketRight_ja Backslash_ja',
'{capslock} KeyA_ja KeyS_ja KeyD_ja KeyF_ja KeyG_ja KeyH_ja KeyJ_ja KeyK_ja KeyL_ja Semicolon_ja Quote_ja {enter}',
'{shiftleft} KeyZ_ja KeyX_ja KeyC_ja KeyV_ja KeyB_ja KeyN_ja KeyM_ja Comma_ja Period_ja Slash_ja {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
]
},
display: {
@@ -186,6 +202,110 @@ export const keyboardOptions = {
RusPeriod: 'Ю',
RusSlash: ',<br/>.',
// Korean (2-set) display
Backquote_ko: '`',
Digit1_ko: '1',
Digit2_ko: '2',
Digit3_ko: '3',
Digit4_ko: '4',
Digit5_ko: '5',
Digit6_ko: '6',
Digit7_ko: '7',
Digit8_ko: '8',
Digit9_ko: '9',
Digit0_ko: '0',
Minus_ko: '-',
Equal_ko: '=',
KeyQ_ko: 'ㅂ',
KeyW_ko: 'ㅈ',
KeyE_ko: 'ㄷ',
KeyR_ko: 'ㄱ',
KeyT_ko: 'ㅅ',
KeyY_ko: 'ㅛ',
KeyU_ko: 'ㅕ',
KeyI_ko: 'ㅑ',
KeyO_ko: 'ㅐ',
KeyP_ko: 'ㅔ',
BracketLeft_ko: '[',
BracketRight_ko: ']',
Backslash_ko: '\\',
KeyA_ko: 'ㅁ',
KeyS_ko: 'ㄴ',
KeyD_ko: 'ㅇ',
KeyF_ko: 'ㄹ',
KeyG_ko: 'ㅎ',
KeyH_ko: 'ㅗ',
KeyJ_ko: 'ㅓ',
KeyK_ko: 'ㅏ',
KeyL_ko: 'ㅣ',
Semicolon_ko: ';',
Quote_ko: "'",
KeyZ_ko: 'ㅋ',
KeyX_ko: 'ㅌ',
KeyC_ko: 'ㅊ',
KeyV_ko: 'ㅍ',
KeyB_ko: 'ㅠ',
KeyN_ko: 'ㅜ',
KeyM_ko: 'ㅡ',
Comma_ko: ',',
Period_ko: '.',
Slash_ko: '/',
// Japanese display (representative hiragana per key)
Backquote_ja: '`',
Digit1_ja: '1',
Digit2_ja: '2',
Digit3_ja: '3',
Digit4_ja: '4',
Digit5_ja: '5',
Digit6_ja: '6',
Digit7_ja: '7',
Digit8_ja: '8',
Digit9_ja: '9',
Digit0_ja: '0',
Minus_ja: '-',
Equal_ja: '=',
KeyQ_ja: 'た',
KeyW_ja: 'て',
KeyE_ja: 'い',
KeyR_ja: 'す',
KeyT_ja: 'か',
KeyY_ja: 'ん',
KeyU_ja: 'な',
KeyI_ja: 'に',
KeyO_ja: 'ら',
KeyP_ja: 'せ',
BracketLeft_ja: '[',
BracketRight_ja: ']',
Backslash_ja: '\\',
KeyA_ja: 'ち',
KeyS_ja: 'と',
KeyD_ja: 'し',
KeyF_ja: 'は',
KeyG_ja: 'き',
KeyH_ja: 'く',
KeyJ_ja: 'ま',
KeyK_ja: 'の',
KeyL_ja: 'り',
Semicolon_ja: ';',
Quote_ja: "'",
KeyZ_ja: 'つ',
KeyX_ja: 'さ',
KeyC_ja: 'そ',
KeyV_ja: 'ひ',
KeyB_ja: 'こ',
KeyN_ja: 'み',
KeyM_ja: 'も',
Comma_ja: ',',
Period_ja: '.',
Slash_ja: '/',
// AZERTY specific display keys
// Row 1
Backquote_azerty: '&#60;<br/>&#62;',

View File

@@ -1,17 +1,56 @@
import { useState } from 'react';
import { CheckOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { CheckOutlined, KeyOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import * as api from '@/api/network.ts';
import { Head } from '@/components/head.tsx';
type State = '' | 'loading' | 'success' | 'failed' | 'denied';
type VerifyState = '' | 'failed' | 'denied';
export const Wifi = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [state, setState] = useState<State>('');
const [apPassword, setApPassword] = useState<string>('');
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [verifying, setVerifying] = useState<boolean>(false);
const [verifyState, setVerifyState] = useState<VerifyState>('');
useEffect(() => {
const pass = searchParams.get('p') || searchParams.get('P');
if (pass) {
verifyPassword(pass);
}
}, []);
async function verifyPassword(password: string) {
if (verifying) return;
setVerifying(true);
setVerifyState('');
try {
const rsp = await api.verifyApLogin(password);
if (rsp?.code === 0) {
setApPassword(password);
setIsAuthenticated(true);
} else {
setVerifyState(rsp?.code === -1 ? 'denied' : 'failed');
}
} catch (err) {
console.error(err);
setVerifyState('failed');
}
setVerifying(false);
}
async function onVerifyFinish(values: any) {
if (!values.apPassword) return;
await verifyPassword(values.apPassword);
}
async function connect(values: any) {
if (!values.ssid || !values.password) return;
@@ -20,13 +59,14 @@ export const Wifi = () => {
setState('loading');
try {
const rsp = await api.connectWifiNoAuth(values.ssid, values.password);
const rsp = await api.connectWifiNoAuth(values.ssid, values.password, apPassword);
switch (rsp?.code) {
case 0:
setState('success');
return;
case -1:
case -4:
setState('denied');
return;
case -2:
@@ -41,6 +81,48 @@ export const Wifi = () => {
setState('success');
}
if (!isAuthenticated) {
return (
<>
<Head title={t('head.wifi')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={onVerifyFinish}
>
<div className="flex flex-col space-y-1 pb-5">
<span className="text-center text-2xl font-semibold text-red-500">
{t('wifi.ap.authTitle')}
</span>
<span className="text-center text-neutral-400">{t('wifi.ap.authDescription')}</span>
</div>
<Form.Item name="apPassword">
<Input.Password prefix={<KeyOutlined />} placeholder={t('wifi.ap.passPlaceholder')} />
</Form.Item>
<Form.Item>
<Button className="w-full" htmlType="submit" type="primary" loading={verifying}>
{t('wifi.ap.verifyBtn')}
</Button>
</Form.Item>
</Form>
<div className="flex max-w-[500px] justify-center px-5 pt-3 md:px-10">
{verifyState === 'failed' && (
<span className="text-sm text-red-500">{t('wifi.ap.authFailed')}</span>
)}
{verifyState === 'denied' && (
<span className="text-sm text-red-500">{t('wifi.invalidMode')}</span>
)}
</div>
</div>
</>
);
}
return (
<>
<Head title={t('head.wifi')} />

View File

@@ -44,6 +44,7 @@ export const router = createHashRouter([
},
{
path: '/wifi',
caseSensitive: false,
lazy: async () => {
const { Wifi } = await import('./pages/wifi');
return { Component: Wifi };