mirror of https://github.com/jetkvm/kvm.git
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package kvm
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
"github.com/prometheus/common/version"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
//go:embed all:static
|
|
var staticFiles embed.FS
|
|
|
|
type WebRTCSessionRequest struct {
|
|
Sd string `json:"sd"`
|
|
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
|
IP string `json:"ip,omitempty"`
|
|
ICEServers []string `json:"iceServers,omitempty"`
|
|
}
|
|
|
|
type SetPasswordRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type ChangePasswordRequest struct {
|
|
OldPassword string `json:"oldPassword"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
|
|
type LocalDevice struct {
|
|
AuthMode *string `json:"authMode"`
|
|
DeviceID string `json:"deviceId"`
|
|
}
|
|
|
|
type DeviceStatus struct {
|
|
IsSetup bool `json:"isSetup"`
|
|
}
|
|
|
|
type SetupRequest struct {
|
|
LocalAuthMode string `json:"localAuthMode"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
func setupRouter() *gin.Engine {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
gin.DisableConsoleColor()
|
|
r := gin.Default()
|
|
|
|
staticFS, _ := fs.Sub(staticFiles, "static")
|
|
|
|
// Add a custom middleware to set cache headers for images
|
|
// This is crucial for optimizing the initial welcome screen load time
|
|
// By enabling caching, we ensure that pre-loaded images are stored in the browser cache
|
|
// This allows for a smoother enter animation and improved user experience on the welcome screen
|
|
r.Use(func(c *gin.Context) {
|
|
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
|
|
ext := filepath.Ext(c.Request.URL.Path)
|
|
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" {
|
|
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
|
|
}
|
|
}
|
|
c.Next()
|
|
})
|
|
|
|
r.StaticFS("/static", http.FS(staticFS))
|
|
r.POST("/auth/login-local", handleLogin)
|
|
|
|
// We use this to determine if the device is setup
|
|
r.GET("/device/status", handleDeviceStatus)
|
|
|
|
// We use this to provide the UI with the device configuration
|
|
r.GET("/device/ui-config.js", handleDeviceUIConfig)
|
|
|
|
// We use this to setup the device in the welcome page
|
|
r.POST("/device/setup", handleSetup)
|
|
|
|
// A Prometheus metrics endpoint.
|
|
version.Version = builtAppVersion
|
|
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
|
|
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
|
|
|
// Protected routes (allows both password and noPassword modes)
|
|
protected := r.Group("/")
|
|
protected.Use(protectedMiddleware())
|
|
{
|
|
protected.POST("/webrtc/session", handleWebRTCSession)
|
|
protected.POST("/cloud/register", handleCloudRegister)
|
|
protected.GET("/device", handleDevice)
|
|
protected.POST("/auth/logout", handleLogout)
|
|
|
|
protected.POST("/auth/password-local", handleCreatePassword)
|
|
protected.PUT("/auth/password-local", handleUpdatePassword)
|
|
protected.DELETE("/auth/local-password", handleDeletePassword)
|
|
protected.POST("/storage/upload", handleUploadHttp)
|
|
}
|
|
|
|
// Catch-all route for SPA
|
|
r.NoRoute(func(c *gin.Context) {
|
|
if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML {
|
|
c.FileFromFS("/", http.FS(staticFS))
|
|
return
|
|
}
|
|
c.Status(http.StatusNotFound)
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// TODO: support multiple sessions?
|
|
var currentSession *Session
|
|
|
|
func handleWebRTCSession(c *gin.Context) {
|
|
var req WebRTCSessionRequest
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
session, err := newSession(SessionConfig{})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
return
|
|
}
|
|
|
|
sd, err := session.ExchangeOffer(req.Sd)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
|
return
|
|
}
|
|
if currentSession != nil {
|
|
writeJSONRPCEvent("otherSessionConnected", nil, currentSession)
|
|
peerConn := currentSession.peerConnection
|
|
go func() {
|
|
time.Sleep(1 * time.Second)
|
|
_ = peerConn.Close()
|
|
}()
|
|
}
|
|
currentSession = session
|
|
c.JSON(http.StatusOK, gin.H{"sd": sd})
|
|
}
|
|
|
|
func handleLogin(c *gin.Context) {
|
|
if config.LocalAuthMode == "noPassword" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Login is disabled in noPassword mode"})
|
|
return
|
|
}
|
|
|
|
var req LoginRequest
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
err := bcrypt.CompareHashAndPassword([]byte(config.HashedPassword), []byte(req.Password))
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
|
return
|
|
}
|
|
|
|
config.LocalAuthToken = uuid.New().String()
|
|
|
|
// Set the cookie
|
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
|
|
}
|
|
|
|
func handleLogout(c *gin.Context) {
|
|
config.LocalAuthToken = ""
|
|
if err := SaveConfig(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
return
|
|
}
|
|
|
|
// Clear the auth cookie
|
|
c.SetCookie("authToken", "", -1, "/", "", false, true)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Logout successful"})
|
|
}
|
|
|
|
func protectedMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if config.LocalAuthMode == "noPassword" {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
authToken, err := c.Cookie("authToken")
|
|
if err != nil || authToken != config.LocalAuthToken || authToken == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func RunWebServer() {
|
|
r := setupRouter()
|
|
//if strings.Contains(builtAppVersion, "-dev") {
|
|
// pprof.Register(r)
|
|
//}
|
|
err := r.Run(":80")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func handleDevice(c *gin.Context) {
|
|
response := LocalDevice{
|
|
AuthMode: &config.LocalAuthMode,
|
|
DeviceID: GetDeviceID(),
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func handleCreatePassword(c *gin.Context) {
|
|
if config.HashedPassword != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password already set"})
|
|
return
|
|
}
|
|
|
|
// We only allow users with noPassword mode to set a new password
|
|
// Users with password mode are not allowed to set a new password without providing the old password
|
|
// We have a PUT endpoint for changing the password, use that instead
|
|
if config.LocalAuthMode != "noPassword" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password mode is not enabled"})
|
|
return
|
|
}
|
|
|
|
var req SetPasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
config.HashedPassword = string(hashedPassword)
|
|
config.LocalAuthToken = uuid.New().String()
|
|
config.LocalAuthMode = "password"
|
|
if err := SaveConfig(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
return
|
|
}
|
|
|
|
// Set the cookie
|
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"message": "Password set successfully"})
|
|
}
|
|
|
|
func handleUpdatePassword(c *gin.Context) {
|
|
if config.HashedPassword == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is not set"})
|
|
return
|
|
}
|
|
|
|
// We only allow users with password mode to change their password
|
|
// Users with noPassword mode are not allowed to change their password
|
|
if config.LocalAuthMode != "password" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password mode is not enabled"})
|
|
return
|
|
}
|
|
|
|
var req ChangePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.OldPassword == "" || req.NewPassword == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(config.HashedPassword), []byte(req.OldPassword)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"})
|
|
return
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash new password"})
|
|
return
|
|
}
|
|
|
|
config.HashedPassword = string(hashedPassword)
|
|
config.LocalAuthToken = uuid.New().String()
|
|
if err := SaveConfig(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
return
|
|
}
|
|
|
|
// Set the cookie
|
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
|
}
|
|
|
|
func handleDeletePassword(c *gin.Context) {
|
|
if config.HashedPassword == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is not set"})
|
|
return
|
|
}
|
|
|
|
if config.LocalAuthMode != "password" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password mode is not enabled"})
|
|
return
|
|
}
|
|
|
|
var req LoginRequest // Reusing LoginRequest struct for password
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(config.HashedPassword), []byte(req.Password)); err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect password"})
|
|
return
|
|
}
|
|
|
|
// Disable password
|
|
config.HashedPassword = ""
|
|
config.LocalAuthToken = ""
|
|
config.LocalAuthMode = "noPassword"
|
|
if err := SaveConfig(); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
|
|
return
|
|
}
|
|
|
|
c.SetCookie("authToken", "", -1, "/", "", false, true)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password disabled successfully"})
|
|
}
|
|
|
|
func handleDeviceStatus(c *gin.Context) {
|
|
response := DeviceStatus{
|
|
IsSetup: config.LocalAuthMode != "",
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func handleDeviceUIConfig(c *gin.Context) {
|
|
config, _ := json.Marshal(gin.H{
|
|
"CLOUD_API": config.CloudURL,
|
|
"DEVICE_VERSION": builtAppVersion,
|
|
})
|
|
if config == nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal config"})
|
|
return
|
|
}
|
|
|
|
response := fmt.Sprintf("window.JETKVM_CONFIG = %s;", config)
|
|
|
|
c.Data(http.StatusOK, "text/javascript; charset=utf-8", []byte(response))
|
|
}
|
|
|
|
func handleSetup(c *gin.Context) {
|
|
// Check if the device is already set up
|
|
if config.LocalAuthMode != "" || config.HashedPassword != "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Device is already set up"})
|
|
return
|
|
}
|
|
|
|
var req SetupRequest
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.LocalAuthMode != "password" && req.LocalAuthMode != "noPassword" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid localAuthMode"})
|
|
return
|
|
}
|
|
|
|
config.LocalAuthMode = req.LocalAuthMode
|
|
|
|
if req.LocalAuthMode == "password" {
|
|
if req.Password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is required for password mode"})
|
|
return
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
config.HashedPassword = string(hashedPassword)
|
|
config.LocalAuthToken = uuid.New().String()
|
|
|
|
// Set the cookie
|
|
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
|
|
|
} else {
|
|
// For noPassword mode, ensure the password field is empty
|
|
config.HashedPassword = ""
|
|
config.LocalAuthToken = ""
|
|
}
|
|
|
|
err := SaveConfig()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save config"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
|
|
}
|