feat(api/host): expose host-setup endpoints and finish CLI test action

Adds /api/host/setup/{preview,keypair,publickey,verify,known-host} and
wires the CLI 'host-setup test' subcommand through the shared
setup.NewClientFromSettings + setup.Verify helpers introduced in this
commit. Mounted under the authenticated router group.
This commit is contained in:
Hintay
2026-05-21 08:36:05 +09:00
parent 70e63ee9a1
commit 4af2a371c9
6 changed files with 224 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
package host
import "github.com/gin-gonic/gin"
func InitRouter(r *gin.RouterGroup) {
g := r.Group("host/setup")
{
g.GET("preview", Preview)
g.POST("preview", Preview)
g.POST("keypair", GenerateKeypair)
g.GET("publickey", GetPublicKey)
g.DELETE("keypair", DeleteKeypair)
g.POST("verify", Verify)
g.POST("known-host", TrustHostKey)
}
}
+120
View File
@@ -0,0 +1,120 @@
package host
import (
"context"
"net/http"
"os"
"time"
"github.com/0xJacky/Nginx-UI/internal/host/setup"
hostssh "github.com/0xJacky/Nginx-UI/internal/host/ssh"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin"
"github.com/uozi-tech/cosy"
)
// Preview renders all snippets from the posted SetupParams (or current
// settings if body is empty). Does not persist anything.
func Preview(c *gin.Context) {
var p setup.SetupParams
if err := c.ShouldBindJSON(&p); err != nil {
p = setup.ParamsFromSettings()
}
r, err := setup.RenderAll(p)
if err != nil {
cosy.ErrHandler(c, err)
return
}
c.JSON(http.StatusOK, r)
}
type keypairResponse struct {
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key,omitempty"`
}
// GenerateKeypair creates a fresh ed25519 keypair, writes the private key to
// HostPrivateKeyPath, returns the public key. The private key is also returned
// once for the caller to display/download — never returned by GetPublicKey().
func GenerateKeypair(c *gin.Context) {
path := settings.NginxSettings.HostPrivateKeyPath
if path == "" {
path = "/etc/nginx-ui/host_key"
}
pub, err := setup.GenerateKeypair(path)
if err != nil {
cosy.ErrHandler(c, err)
return
}
priv, _ := os.ReadFile(path)
c.JSON(http.StatusOK, keypairResponse{PublicKey: pub, PrivateKey: string(priv)})
}
func GetPublicKey(c *gin.Context) {
path := settings.NginxSettings.HostPrivateKeyPath
pub, err := setup.LoadPublicKey(path)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"public_key": ""})
return
}
c.JSON(http.StatusOK, gin.H{"public_key": pub})
}
func DeleteKeypair(c *gin.Context) {
path := settings.NginxSettings.HostPrivateKeyPath
if path == "" {
c.JSON(http.StatusNoContent, nil)
return
}
_ = os.Remove(path)
c.JSON(http.StatusNoContent, nil)
}
type verifyRequest struct {
SkipNginxT bool `json:"skip_nginx_t"`
}
func Verify(c *gin.Context) {
var req verifyRequest
_ = c.ShouldBindJSON(&req)
client, err := setup.NewClientFromSettings()
if err != nil {
cosy.ErrHandler(c, err)
return
}
defer client.Close()
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
result := setup.Verify(ctx, setup.VerifyOptions{
Client: client,
Params: setup.ParamsFromSettings(),
SkipNginxT: req.SkipNginxT,
})
c.JSON(http.StatusOK, result)
}
type knownHostRequest struct {
HostAddress string `json:"host_address" binding:"required"`
Fingerprint string `json:"fingerprint" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
}
// TrustHostKey appends a known_hosts entry after the user confirms a fingerprint.
func TrustHostKey(c *gin.Context) {
var req knownHostRequest
if !cosy.BindAndValid(c, &req) {
return
}
path := settings.NginxSettings.HostKnownHostsPath
if path == "" {
path = "/etc/nginx-ui/known_hosts"
}
if err := hostssh.TrustHostKey(path, req.HostAddress, req.PublicKey); err != nil {
cosy.ErrHandler(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "trusted"})
}
+11 -3
View File
@@ -136,8 +136,16 @@ func hostSetupPrint(ctx context.Context, c *cli.Command) error {
return nil
}
// hostSetupTest is a stub here — Task 14 wires it through the shared
// setup.NewClientFromSettings + setup.Verify helpers introduced in that task.
func hostSetupTest(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("host-setup test: not yet wired to live settings; use the Web UI verify endpoint until Task 14 lands")
client, err := setup.NewClientFromSettings()
if err != nil {
return err
}
defer client.Close()
result := setup.Verify(ctx, setup.VerifyOptions{
Client: client,
Params: setup.ParamsFromSettings(),
})
return json.NewEncoder(os.Stdout).Encode(result)
}
+55
View File
@@ -0,0 +1,55 @@
package setup
import (
hostssh "github.com/0xJacky/Nginx-UI/internal/host/ssh"
"github.com/0xJacky/Nginx-UI/settings"
)
// NewClientFromSettings constructs a hostssh.Client using the currently loaded
// settings.NginxSettings. The returned client is single-use for verify flows;
// the long-lived client used by sshRunner is independent.
func NewClientFromSettings() (*hostssh.Client, error) {
n := settings.NginxSettings
kh, err := hostssh.NewKnownHosts(n.HostKnownHostsPath)
if err != nil {
return nil, err
}
sudo := n.HostSudoPrefix
if sudo == "" {
sudo = "sudo -n"
}
systemctl := n.HostSystemctlPath
if systemctl == "" {
systemctl = "/bin/systemctl"
}
return hostssh.NewClient(hostssh.ClientOptions{
Address: n.HostAddress,
User: n.HostUser,
AuthMethod: n.HostAuthMethod,
PrivateKeyPath: n.HostPrivateKeyPath,
KnownHosts: kh,
Strict: n.HostStrictHostKey,
Config: hostssh.Config{
SudoPrefix: sudo,
SystemctlPath: systemctl,
NginxSbinPath: n.SbinPath,
},
}), nil
}
// ParamsFromSettings builds a SetupParams reflecting current settings.
func ParamsFromSettings() SetupParams {
n := settings.NginxSettings
p := SetupParams{
HostAddress: n.HostAddress,
HostUser: n.HostUser,
SystemdUnit: n.HostSystemdUnitName,
SystemctlPath: n.HostSystemctlPath,
NginxSbinPath: n.SbinPath,
HostConfigDir: n.HostConfigDir,
HostLogDir: n.HostLogDir,
ContainerKeyPath: n.HostPrivateKeyPath,
ContainerKnownHostsPath: n.HostKnownHostsPath,
}
return p.FillDefaults()
}
+20
View File
@@ -0,0 +1,20 @@
package ssh
import (
"github.com/uozi-tech/cosy"
gossh "golang.org/x/crypto/ssh"
)
// TrustHostKey parses an OpenSSH-formatted public key string and appends it to
// the known_hosts file at path. Returns ErrKnownHostsWrite on file errors.
func TrustHostKey(path, hostPort, publicKeyOpenSSH string) error {
kh, err := NewKnownHosts(path)
if err != nil {
return err
}
parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(publicKeyOpenSSH))
if err != nil {
return cosy.WrapErrorWithParams(ErrHostKeyMismatch, "parse public key", err.Error())
}
return kh.Trust(hostPort, parsed)
}
+2
View File
@@ -14,6 +14,7 @@ import (
"github.com/0xJacky/Nginx-UI/api/event"
"github.com/0xJacky/Nginx-UI/api/external_notify"
"github.com/0xJacky/Nginx-UI/api/geolite"
"github.com/0xJacky/Nginx-UI/api/host"
"github.com/0xJacky/Nginx-UI/api/license"
"github.com/0xJacky/Nginx-UI/api/llm"
"github.com/0xJacky/Nginx-UI/api/nginx"
@@ -103,6 +104,7 @@ func InitRouter() {
settings.InitRouter(g)
llm.InitRouter(g)
cluster.InitRouter(g)
host.InitRouter(g)
notification.InitRouter(g)
external_notify.InitRouter(g)
backup.InitAutoBackupRouter(g)