REST API: add a method to get the status of the services
added a status page to the built-in web admin
This commit is contained in:
parent
6977a4a18b
commit
50982229e1
22 changed files with 424 additions and 71 deletions
|
@ -152,6 +152,13 @@ type UserActions struct {
|
|||
Hook string `json:"hook" mapstructure:"hook"`
|
||||
}
|
||||
|
||||
// ProviderStatus defines the provider status
|
||||
type ProviderStatus struct {
|
||||
Driver string `json:"driver"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Config provider configuration
|
||||
type Config struct {
|
||||
// Driver name, must be one of the SupportedProviders
|
||||
|
@ -775,8 +782,18 @@ func ParseDumpData(data []byte) (BackupData, error) {
|
|||
}
|
||||
|
||||
// GetProviderStatus returns an error if the provider is not available
|
||||
func GetProviderStatus() error {
|
||||
return provider.checkAvailability()
|
||||
func GetProviderStatus() ProviderStatus {
|
||||
err := provider.checkAvailability()
|
||||
status := ProviderStatus{
|
||||
Driver: config.Driver,
|
||||
}
|
||||
if err == nil {
|
||||
status.IsActive = true
|
||||
} else {
|
||||
status.IsActive = false
|
||||
status.Error = err.Error()
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// Close releases all provider resources.
|
||||
|
|
30
ftpd/ftpd.go
30
ftpd/ftpd.go
|
@ -2,6 +2,7 @@
|
|||
package ftpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
ftpserver "github.com/fclairamb/ftpserverlib"
|
||||
|
@ -26,6 +27,14 @@ type PortRange struct {
|
|||
End int `json:"end" mapstructure:"end"`
|
||||
}
|
||||
|
||||
// ServiceStatus defines the service status
|
||||
type ServiceStatus struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
Address string `json:"address"`
|
||||
PassivePortRange PortRange `json:"passive_port_range"`
|
||||
FTPES string `json:"ftpes"`
|
||||
}
|
||||
|
||||
// Configuration defines the configuration for the ftp server
|
||||
type Configuration struct {
|
||||
// The port used for serving FTP requests
|
||||
|
@ -61,6 +70,19 @@ func (c *Configuration) Initialize(configDir string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.status = ServiceStatus{
|
||||
IsActive: true,
|
||||
Address: fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort),
|
||||
PassivePortRange: c.PassivePortRange,
|
||||
FTPES: "Disabled",
|
||||
}
|
||||
if c.CertificateFile != "" && c.CertificateKeyFile != "" {
|
||||
if c.TLSMode == 1 {
|
||||
server.status.FTPES = "Required"
|
||||
} else {
|
||||
server.status.FTPES = "Enabled"
|
||||
}
|
||||
}
|
||||
ftpServer := ftpserver.NewFtpServer(server)
|
||||
return ftpServer.ListenAndServe()
|
||||
}
|
||||
|
@ -73,6 +95,14 @@ func ReloadTLSCertificate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the server status
|
||||
func GetStatus() ServiceStatus {
|
||||
if server == nil {
|
||||
return ServiceStatus{}
|
||||
}
|
||||
return server.status
|
||||
}
|
||||
|
||||
func getConfigPath(name, configDir string) string {
|
||||
if !utils.IsFileInputValid(name) {
|
||||
return ""
|
||||
|
|
|
@ -155,6 +155,12 @@ func TestMain(m *testing.M) {
|
|||
preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
|
||||
postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
|
||||
|
||||
status := ftpd.GetStatus()
|
||||
if status.IsActive {
|
||||
logger.ErrorToConsole("ftpd is already active")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Debug(logSender, "", "initializing FTP server with config %+v", ftpdConf)
|
||||
if err := ftpdConf.Initialize(configDir); err != nil {
|
||||
|
@ -185,6 +191,18 @@ func TestMain(m *testing.M) {
|
|||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func TestInitialization(t *testing.T) {
|
||||
ftpdConf := config.GetFTPDConfig()
|
||||
ftpdConf.BindPort = 2121
|
||||
ftpdConf.CertificateFile = filepath.Join(os.TempDir(), "test_ftpd.crt")
|
||||
ftpdConf.CertificateKeyFile = filepath.Join(os.TempDir(), "test_ftpd.key")
|
||||
ftpdConf.TLSMode = 1
|
||||
err := ftpdConf.Initialize(configDir)
|
||||
assert.Error(t, err)
|
||||
status := ftpd.GetStatus()
|
||||
assert.True(t, status.IsActive)
|
||||
}
|
||||
|
||||
func TestBasicFTPHandling(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.QuotaSize = 6553600
|
||||
|
|
|
@ -24,6 +24,7 @@ type Server struct {
|
|||
certMgr *common.CertManager
|
||||
initialMsg string
|
||||
statusBanner string
|
||||
status ServiceStatus
|
||||
}
|
||||
|
||||
// NewServer returns a new FTP server driver
|
||||
|
@ -37,7 +38,7 @@ func NewServer(config *Configuration, configDir string) (*Server, error) {
|
|||
}
|
||||
certificateFile := getConfigPath(config.CertificateFile, configDir)
|
||||
certificateKeyFile := getConfigPath(config.CertificateKeyFile, configDir)
|
||||
if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
|
||||
if certificateFile != "" && certificateKeyFile != "" {
|
||||
server.certMgr, err = common.NewCertManager(certificateFile, certificateKeyFile, logSender)
|
||||
if err != nil {
|
||||
return server, err
|
||||
|
|
|
@ -428,17 +428,17 @@ func GetVersion(expectedStatusCode int) (version.Info, []byte, error) {
|
|||
return appVersion, body, err
|
||||
}
|
||||
|
||||
// GetProviderStatus returns provider status
|
||||
func GetProviderStatus(expectedStatusCode int) (map[string]interface{}, []byte, error) {
|
||||
var response map[string]interface{}
|
||||
// GetStatus returns the server status
|
||||
func GetStatus(expectedStatusCode int) (ServicesStatus, []byte, error) {
|
||||
var response ServicesStatus
|
||||
var body []byte
|
||||
resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(providerStatusPath), nil, "")
|
||||
resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(serverStatusPath), nil, "")
|
||||
if err != nil {
|
||||
return response, body, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = checkResponse(resp.StatusCode, expectedStatusCode)
|
||||
if err == nil && (expectedStatusCode == http.StatusOK || expectedStatusCode == http.StatusInternalServerError) {
|
||||
if err == nil && (expectedStatusCode == http.StatusOK) {
|
||||
err = render.DecodeJSON(resp.Body, &response)
|
||||
} else {
|
||||
body, _ = getResponseBody(resp)
|
||||
|
|
|
@ -16,8 +16,12 @@ import (
|
|||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/ftpd"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/webdavd"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -29,7 +33,7 @@ const (
|
|||
userPath = "/api/v1/user"
|
||||
versionPath = "/api/v1/version"
|
||||
folderPath = "/api/v1/folder"
|
||||
providerStatusPath = "/api/v1/providerstatus"
|
||||
serverStatusPath = "/api/v1/status"
|
||||
dumpDataPath = "/api/v1/dumpdata"
|
||||
loadDataPath = "/api/v1/loaddata"
|
||||
updateUsedQuotaPath = "/api/v1/quota_update"
|
||||
|
@ -42,6 +46,7 @@ const (
|
|||
webConnectionsPath = "/web/connections"
|
||||
webFoldersPath = "/web/folders"
|
||||
webFolderPath = "/web/folder"
|
||||
webStatusPath = "/web/status"
|
||||
webStaticFilesPath = "/static"
|
||||
// MaxRestoreSize defines the max size for the loaddata input file
|
||||
MaxRestoreSize = 10485760 // 10 MB
|
||||
|
@ -55,6 +60,14 @@ var (
|
|||
certMgr *common.CertManager
|
||||
)
|
||||
|
||||
// ServicesStatus keep the state of the running services
|
||||
type ServicesStatus struct {
|
||||
SSH sftpd.ServiceStatus `json:"ssh"`
|
||||
FTP ftpd.ServiceStatus `json:"ftp"`
|
||||
WebDAV webdavd.ServiceStatus `json:"webdav"`
|
||||
DataProvider dataprovider.ProviderStatus `json:"data_provider"`
|
||||
}
|
||||
|
||||
// Conf httpd daemon configuration
|
||||
type Conf struct {
|
||||
// The port used for serving HTTP requests. 0 disable the HTTP server. Default: 8080
|
||||
|
@ -155,3 +168,13 @@ func getConfigPath(name, configDir string) string {
|
|||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func getServicesStatus() ServicesStatus {
|
||||
status := ServicesStatus{
|
||||
SSH: sftpd.GetStatus(),
|
||||
FTP: ftpd.GetStatus(),
|
||||
WebDAV: webdavd.GetStatus(),
|
||||
DataProvider: dataprovider.GetProviderStatus(),
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ const (
|
|||
userPath = "/api/v1/user"
|
||||
folderPath = "/api/v1/folder"
|
||||
activeConnectionsPath = "/api/v1/connection"
|
||||
serverStatusPath = "/api/v1/status"
|
||||
quotaScanPath = "/api/v1/quota_scan"
|
||||
quotaScanVFolderPath = "/api/v1/folder_quota_scan"
|
||||
updateUsedQuotaPath = "/api/v1/quota_update"
|
||||
|
@ -58,6 +59,7 @@ const (
|
|||
webFoldersPath = "/web/folders"
|
||||
webFolderPath = "/web/folder"
|
||||
webConnectionsPath = "/web/connections"
|
||||
webStatusPath = "/web/status"
|
||||
configDir = ".."
|
||||
httpsCert = `-----BEGIN CERTIFICATE-----
|
||||
MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
|
||||
|
@ -1763,10 +1765,10 @@ func TestGetVersion(t *testing.T) {
|
|||
assert.Error(t, err, "get version request must succeed, we requested to check a wrong status code")
|
||||
}
|
||||
|
||||
func TestGetProviderStatus(t *testing.T) {
|
||||
_, _, err := httpd.GetProviderStatus(http.StatusOK)
|
||||
func TestGetStatus(t *testing.T) {
|
||||
_, _, err := httpd.GetStatus(http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
_, _, err = httpd.GetProviderStatus(http.StatusBadRequest)
|
||||
_, _, err = httpd.GetStatus(http.StatusBadRequest)
|
||||
assert.Error(t, err, "get provider status request must succeed, we requested to check a wrong status code")
|
||||
}
|
||||
|
||||
|
@ -1905,8 +1907,10 @@ func TestProviderErrors(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
_, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: "apath"}, http.StatusInternalServerError)
|
||||
assert.NoError(t, err)
|
||||
_, _, err = httpd.GetProviderStatus(http.StatusInternalServerError)
|
||||
assert.NoError(t, err)
|
||||
status, _, err := httpd.GetStatus(http.StatusOK)
|
||||
if assert.NoError(t, err) {
|
||||
assert.False(t, status.DataProvider.IsActive)
|
||||
}
|
||||
_, _, err = httpd.Dumpdata("backup.json", "", http.StatusInternalServerError)
|
||||
assert.NoError(t, err)
|
||||
_, _, err = httpd.GetFolders(0, 0, "", http.StatusInternalServerError)
|
||||
|
@ -2779,6 +2783,12 @@ func TestGetConnectionsMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestGetStatusMock(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, serverStatusPath, nil)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestDeleteActiveConnectionMock(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodDelete, activeConnectionsPath+"/connectionID", nil)
|
||||
rr := executeRequest(req)
|
||||
|
@ -3713,6 +3723,12 @@ func TestGetWebConnectionsMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestGetWebStatusMock(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, webStatusPath, nil)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestStaticFilesMock(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, "/static/favicon.ico", nil)
|
||||
rr := executeRequest(req)
|
||||
|
|
|
@ -549,7 +549,7 @@ func TestApiCallToNotListeningServer(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
_, _, err = GetVersion(http.StatusOK)
|
||||
assert.Error(t, err)
|
||||
_, _, err = GetProviderStatus(http.StatusOK)
|
||||
_, _, err = GetStatus(http.StatusOK)
|
||||
assert.Error(t, err)
|
||||
_, _, err = Dumpdata("backup.json", "0", http.StatusOK)
|
||||
assert.Error(t, err)
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"github.com/go-chi/render"
|
||||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/metrics"
|
||||
"github.com/drakkan/sftpgo/version"
|
||||
|
@ -66,13 +65,8 @@ func initializeRouter(staticFilesPath string, enableProfiler, enableWebAdmin boo
|
|||
render.JSON(w, r, version.Get())
|
||||
})
|
||||
|
||||
router.Get(providerStatusPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
err := dataprovider.GetProviderStatus()
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
|
||||
} else {
|
||||
sendAPIResponse(w, r, err, "Alive", http.StatusOK)
|
||||
}
|
||||
router.Get(serverStatusPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, getServicesStatus())
|
||||
})
|
||||
|
||||
router.Get(activeConnectionsPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -106,6 +100,7 @@ func initializeRouter(staticFilesPath string, enableProfiler, enableWebAdmin boo
|
|||
router.Get(webFoldersPath, handleWebGetFolders)
|
||||
router.Get(webFolderPath, handleWebAddFolderGet)
|
||||
router.Post(webFolderPath, handleWebAddFolderPost)
|
||||
router.Get(webStatusPath, handleWebGetStatus)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
openapi: 3.0.3
|
||||
info:
|
||||
title: SFTPGo
|
||||
description: 'SFTPGo REST API'
|
||||
version: 2.2.0
|
||||
description: SFTPGo REST API
|
||||
version: 2.2.1
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
@ -29,7 +29,7 @@ paths:
|
|||
/version:
|
||||
get:
|
||||
tags:
|
||||
- version
|
||||
- maintenance
|
||||
summary: Get version details
|
||||
operationId: get_version
|
||||
responses:
|
||||
|
@ -47,29 +47,6 @@ paths:
|
|||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/providerstatus:
|
||||
get:
|
||||
tags:
|
||||
- providerstatus
|
||||
summary: Get data provider status
|
||||
operationId: get_provider_status
|
||||
responses:
|
||||
200:
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
example:
|
||||
message: "Alive"
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
403:
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
500:
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/connection:
|
||||
get:
|
||||
tags:
|
||||
|
@ -667,12 +644,35 @@ paths:
|
|||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/status:
|
||||
get:
|
||||
tags:
|
||||
- maintenance
|
||||
summary: Retrieve the status of the active services
|
||||
operationId: get_status
|
||||
responses:
|
||||
200:
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServicesStatus'
|
||||
400:
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
401:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
403:
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
500:
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/dumpdata:
|
||||
get:
|
||||
tags:
|
||||
- maintenance
|
||||
summary: Backup SFTPGo data serializing them as JSON
|
||||
description: The backup is saved to a local file to avoid to expose users hashed passwords over the network. The output of dumpdata can be used as input for loaddata
|
||||
summary: Backup SFTPGo data as data provider independent JSON
|
||||
description: The backup is saved to a local file to avoid to expose sensitive data over the network. The output of dumpdata can be used as input for loaddata
|
||||
operationId: dumpdata
|
||||
parameters:
|
||||
- in: query
|
||||
|
@ -1339,6 +1339,83 @@ components:
|
|||
type: integer
|
||||
format: int64
|
||||
description: scan start time as unix timestamp in milliseconds
|
||||
SSHHostKey:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
fingerprint:
|
||||
type: string
|
||||
BaseServiceStatus:
|
||||
type: object
|
||||
properties:
|
||||
is_active:
|
||||
type: boolean
|
||||
address:
|
||||
type: string
|
||||
description: TCP address the server listen on in the form "host:port"
|
||||
SSHServiceStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseServiceStatus'
|
||||
- type: object
|
||||
properties:
|
||||
host_keys:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SSHHostKey'
|
||||
ssh_commands:
|
||||
type: string
|
||||
description: accepted SSH commands comma separated
|
||||
FTPPassivePortRange:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
type: integer
|
||||
end:
|
||||
type: integer
|
||||
FTPServiceStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseServiceStatus'
|
||||
- type: object
|
||||
properties:
|
||||
passive_port_range:
|
||||
$ref: '#/components/schemas/FTPPassivePortRange'
|
||||
ftpes:
|
||||
type: string
|
||||
enum:
|
||||
- Disabled
|
||||
- Enabled
|
||||
- Required
|
||||
WebDAVServiceStatus:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BaseServiceStatus'
|
||||
- type: object
|
||||
properties:
|
||||
protocol:
|
||||
type: string
|
||||
enum:
|
||||
- HTTP
|
||||
- HTTPS
|
||||
DataProviderStatus:
|
||||
type: object
|
||||
properties:
|
||||
is_active:
|
||||
type: boolean
|
||||
driver:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
ServicesStatus:
|
||||
type: object
|
||||
properties:
|
||||
ssh:
|
||||
$ref: '#/components/schemas/SSHServiceStatus'
|
||||
ftp:
|
||||
$ref: '#/components/schemas/FTPServiceStatus'
|
||||
webdav:
|
||||
$ref: '#/components/schemas/WebDAVServiceStatus'
|
||||
data_provider:
|
||||
$ref: '#/components/schemas/DataProviderStatus'
|
||||
ApiResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
|
25
httpd/web.go
25
httpd/web.go
|
@ -30,8 +30,10 @@ const (
|
|||
templateFolders = "folders.html"
|
||||
templateFolder = "folder.html"
|
||||
templateMessage = "message.html"
|
||||
templateStatus = "status.html"
|
||||
pageUsersTitle = "Users"
|
||||
pageConnectionsTitle = "Connections"
|
||||
pageStatusTitle = "Status"
|
||||
pageFoldersTitle = "Folders"
|
||||
page400Title = "Bad request"
|
||||
page404Title = "Not found"
|
||||
|
@ -60,9 +62,11 @@ type basePage struct {
|
|||
FolderURL string
|
||||
APIFoldersURL string
|
||||
APIFolderQuotaScanURL string
|
||||
StatusURL string
|
||||
UsersTitle string
|
||||
ConnectionsTitle string
|
||||
FoldersTitle string
|
||||
StatusTitle string
|
||||
Version string
|
||||
}
|
||||
|
||||
|
@ -81,6 +85,11 @@ type connectionsPage struct {
|
|||
Connections []common.ConnectionStatus
|
||||
}
|
||||
|
||||
type statusPage struct {
|
||||
basePage
|
||||
Status ServicesStatus
|
||||
}
|
||||
|
||||
type userPage struct {
|
||||
basePage
|
||||
User dataprovider.User
|
||||
|
@ -131,12 +140,17 @@ func loadTemplates(templatesPath string) {
|
|||
filepath.Join(templatesPath, templateBase),
|
||||
filepath.Join(templatesPath, templateFolder),
|
||||
}
|
||||
statusPath := []string{
|
||||
filepath.Join(templatesPath, templateBase),
|
||||
filepath.Join(templatesPath, templateStatus),
|
||||
}
|
||||
usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...))
|
||||
userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...))
|
||||
connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...))
|
||||
messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...))
|
||||
foldersTmpl := utils.LoadTemplate(template.ParseFiles(foldersPath...))
|
||||
folderTmpl := utils.LoadTemplate(template.ParseFiles(folderPath...))
|
||||
statusTmpl := utils.LoadTemplate(template.ParseFiles(statusPath...))
|
||||
|
||||
templates[templateUsers] = usersTmpl
|
||||
templates[templateUser] = userTmpl
|
||||
|
@ -144,6 +158,7 @@ func loadTemplates(templatesPath string) {
|
|||
templates[templateMessage] = messageTmpl
|
||||
templates[templateFolders] = foldersTmpl
|
||||
templates[templateFolder] = folderTmpl
|
||||
templates[templateStatus] = statusTmpl
|
||||
}
|
||||
|
||||
func getBasePageData(title, currentURL string) basePage {
|
||||
|
@ -160,9 +175,11 @@ func getBasePageData(title, currentURL string) basePage {
|
|||
APIFoldersURL: folderPath,
|
||||
APIFolderQuotaScanURL: quotaScanVFolderPath,
|
||||
ConnectionsURL: webConnectionsPath,
|
||||
StatusURL: webStatusPath,
|
||||
UsersTitle: pageUsersTitle,
|
||||
ConnectionsTitle: pageConnectionsTitle,
|
||||
FoldersTitle: pageFoldersTitle,
|
||||
StatusTitle: pageStatusTitle,
|
||||
Version: version.GetAsString(),
|
||||
}
|
||||
}
|
||||
|
@ -725,6 +742,14 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func handleWebGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data := statusPage{
|
||||
basePage: getBasePageData(pageStatusTitle, webStatusPath),
|
||||
Status: getServicesStatus(),
|
||||
}
|
||||
renderTemplate(w, templateStatus, data)
|
||||
}
|
||||
|
||||
func handleWebGetConnections(w http.ResponseWriter, r *http.Request) {
|
||||
connectionStats := common.Connections.GetStats()
|
||||
data := connectionsPage{
|
||||
|
|
|
@ -166,6 +166,7 @@ func (c *Configuration) Initialize(configDir string) error {
|
|||
}
|
||||
|
||||
if err := c.checkAndLoadHostKeys(configDir, serverConfig); err != nil {
|
||||
serviceStatus.HostKeys = nil
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -180,7 +181,8 @@ func (c *Configuration) Initialize(configDir string) error {
|
|||
c.configureLoginBanner(serverConfig, configDir)
|
||||
c.checkSSHCommands()
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort))
|
||||
addr := fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "error starting listener on address %s:%d: %v", c.BindAddress, c.BindPort, err)
|
||||
return err
|
||||
|
@ -191,6 +193,9 @@ func (c *Configuration) Initialize(configDir string) error {
|
|||
return err
|
||||
}
|
||||
logger.Info(logSender, "", "server listener registered address: %v", listener.Addr().String())
|
||||
serviceStatus.Address = addr
|
||||
serviceStatus.IsActive = true
|
||||
serviceStatus.SSHCommands = strings.Join(c.EnabledSSHCommands, ", ")
|
||||
|
||||
for {
|
||||
var conn net.Conn
|
||||
|
@ -563,8 +568,8 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
|
|||
if err := c.checkHostKeyAutoGeneration(configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range c.HostKeys {
|
||||
hostKey := k
|
||||
serviceStatus.HostKeys = nil
|
||||
for _, hostKey := range c.HostKeys {
|
||||
if !utils.IsFileInputValid(hostKey) {
|
||||
logger.Warn(logSender, "", "unable to load invalid host key %#v", hostKey)
|
||||
logger.WarnToConsole("unable to load invalid host key %#v", hostKey)
|
||||
|
@ -584,8 +589,13 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k := HostKey{
|
||||
Path: hostKey,
|
||||
Fingerprint: ssh.FingerprintSHA256(private.PublicKey()),
|
||||
}
|
||||
serviceStatus.HostKeys = append(serviceStatus.HostKeys, k)
|
||||
logger.Info(logSender, "", "Host key %#v loaded, type %#v, fingerprint %#v", hostKey,
|
||||
private.PublicKey().Type(), ssh.FingerprintSHA256(private.PublicKey()))
|
||||
private.PublicKey().Type(), k.Fingerprint)
|
||||
|
||||
// Add private key to the server configuration.
|
||||
serverConfig.AddHostKey(private)
|
||||
|
|
|
@ -18,6 +18,7 @@ var (
|
|||
defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"}
|
||||
sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
|
||||
systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
|
||||
serviceStatus ServiceStatus
|
||||
)
|
||||
|
||||
type sshSubsystemExitStatus struct {
|
||||
|
@ -28,6 +29,25 @@ type sshSubsystemExecMsg struct {
|
|||
Command string
|
||||
}
|
||||
|
||||
// HostKey defines the details for a used host key
|
||||
type HostKey struct {
|
||||
Path string `json:"path"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// ServiceStatus defines the service status
|
||||
type ServiceStatus struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
Address string `json:"address"`
|
||||
SSHCommands string `json:"ssh_commands"`
|
||||
HostKeys []HostKey `json:"host_keys"`
|
||||
}
|
||||
|
||||
// GetStatus returns the server status
|
||||
func GetStatus() ServiceStatus {
|
||||
return serviceStatus
|
||||
}
|
||||
|
||||
// GetDefaultSSHCommands returns the SSH commands enabled as default
|
||||
func GetDefaultSSHCommands() []string {
|
||||
result := make([]string, len(defaultSSHCommands))
|
||||
|
|
|
@ -43,6 +43,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/httpd"
|
||||
"github.com/drakkan/sftpgo/kms"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
@ -367,6 +368,8 @@ func TestBasicSFTPHandling(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
err = os.RemoveAll(user.GetHomeDir())
|
||||
assert.NoError(t, err)
|
||||
status := sftpd.GetStatus()
|
||||
assert.True(t, status.IsActive)
|
||||
}
|
||||
|
||||
func TestOpenReadWrite(t *testing.T) {
|
||||
|
|
1
static/vendor/fontawesome-free/svgs/solid/info-circle.svg
vendored
Normal file
1
static/vendor/fontawesome-free/svgs/solid/info-circle.svg
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"/></svg>
|
After Width: | Height: | Size: 659 B |
|
@ -71,6 +71,12 @@
|
|||
<span>{{.ConnectionsTitle}}</span></a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item {{if eq .CurrentURL .StatusURL}}active{{end}}">
|
||||
<a class="nav-link" href="{{.StatusURL}}">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>{{.StatusTitle}}</span></a>
|
||||
</li>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="sidebar-divider d-none d-md-block">
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card mb-4 border-left-success">
|
||||
<div class="card mb-4 border-left-info">
|
||||
<div class="card-body">No user connected</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
73
templates/status.html
Normal file
73
templates/status.html
Normal file
|
@ -0,0 +1,73 @@
|
|||
{{template "base" .}}
|
||||
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "page_body"}}
|
||||
|
||||
<div class="card mb-4 {{ if .Status.SSH.IsActive}}border-left-success{{else}}border-left-info{{end}}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SFTP/SSH server</h5>
|
||||
<p class="card-text">
|
||||
Status: {{ if .Status.SSH.IsActive}}"Started"{{else}}"Stopped"{{end}}
|
||||
{{if .Status.SSH.IsActive}}
|
||||
<br>
|
||||
Address: "{{.Status.SSH.Address}}"
|
||||
<br>
|
||||
Accepted commands: "{{.Status.SSH.SSHCommands}}"
|
||||
<br>
|
||||
{{range .Status.SSH.HostKeys}}
|
||||
<br>
|
||||
Host Key: "{{.Path}}"
|
||||
<br>
|
||||
Fingerprint: "{{.Fingerprint}}"
|
||||
<br>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 {{ if .Status.FTP.IsActive}}border-left-success{{else}}border-left-info{{end}}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">FTP server</h5>
|
||||
<p class="card-text">
|
||||
Status: {{ if .Status.FTP.IsActive}}"Started"{{else}}"Stopped"{{end}}
|
||||
{{if .Status.FTP.IsActive}}
|
||||
<br>
|
||||
Address: "{{.Status.FTP.Address}}"
|
||||
<br>
|
||||
Passive port range: "{{.Status.FTP.PassivePortRange.Start}}-{{.Status.FTP.PassivePortRange.End}}"
|
||||
<br>
|
||||
TLS: "{{.Status.FTP.FTPES}}"
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 {{ if .Status.WebDAV.IsActive}}border-left-success{{else}}border-left-info{{end}}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">WebDAV server</h5>
|
||||
<p class="card-text">
|
||||
Status: {{ if .Status.WebDAV.IsActive}}"Started"{{else}}"Stopped"{{end}}
|
||||
{{if .Status.WebDAV.IsActive}}
|
||||
<br>
|
||||
Address: "{{.Status.WebDAV.Address}}"
|
||||
<br>
|
||||
Protocol: "{{.Status.WebDAV.Protocol}}"
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 {{ if .Status.DataProvider.IsActive}}border-left-success{{else}}border-left-warning{{end}}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Data provider</h5>
|
||||
<p class="card-text">
|
||||
Status: {{ if .Status.DataProvider.IsActive}}"OK"{{else}}"{{.Status.DataProvider.Error}}"{{end}}
|
||||
<br>
|
||||
Driver: "{{.Status.DataProvider.Driver}}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{end}}
|
|
@ -22,7 +22,6 @@ import (
|
|||
|
||||
"github.com/drakkan/sftpgo/common"
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/httpd"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
|
@ -665,7 +664,9 @@ func TestBasicUsersCache(t *testing.T) {
|
|||
}
|
||||
u.Permissions = make(map[string][]string)
|
||||
u.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
err := dataprovider.AddUser(u)
|
||||
assert.NoError(t, err)
|
||||
user, err := dataprovider.UserExists(u.Username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
c := &Configuration{
|
||||
|
@ -728,7 +729,7 @@ func TestBasicUsersCache(t *testing.T) {
|
|||
assert.False(t, cachedUser.IsExpired())
|
||||
}
|
||||
// cache is invalidated after a user modification
|
||||
user, _, err = httpd.UpdateUser(user, http.StatusOK, "")
|
||||
err = dataprovider.UpdateUser(user)
|
||||
assert.NoError(t, err)
|
||||
_, ok = dataprovider.GetCachedWebDAVUser(username)
|
||||
assert.False(t, ok)
|
||||
|
@ -739,7 +740,7 @@ func TestBasicUsersCache(t *testing.T) {
|
|||
_, ok = dataprovider.GetCachedWebDAVUser(username)
|
||||
assert.True(t, ok)
|
||||
// cache is invalidated after user deletion
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
err = dataprovider.DeleteUser(user)
|
||||
assert.NoError(t, err)
|
||||
_, ok = dataprovider.GetCachedWebDAVUser(username)
|
||||
assert.False(t, ok)
|
||||
|
@ -757,19 +758,27 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
|
|||
u.Password = password + "1"
|
||||
u.Permissions = make(map[string][]string)
|
||||
u.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
user1, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
err := dataprovider.AddUser(u)
|
||||
assert.NoError(t, err)
|
||||
user1, err := dataprovider.UserExists(u.Username)
|
||||
assert.NoError(t, err)
|
||||
u.Username = username + "2"
|
||||
u.Password = password + "2"
|
||||
user2, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
err = dataprovider.AddUser(u)
|
||||
assert.NoError(t, err)
|
||||
user2, err := dataprovider.UserExists(u.Username)
|
||||
assert.NoError(t, err)
|
||||
u.Username = username + "3"
|
||||
u.Password = password + "3"
|
||||
user3, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
err = dataprovider.AddUser(u)
|
||||
assert.NoError(t, err)
|
||||
user3, err := dataprovider.UserExists(u.Username)
|
||||
assert.NoError(t, err)
|
||||
u.Username = username + "4"
|
||||
u.Password = password + "4"
|
||||
user4, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
err = dataprovider.AddUser(u)
|
||||
assert.NoError(t, err)
|
||||
user4, err := dataprovider.UserExists(u.Username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
c := &Configuration{
|
||||
|
@ -878,7 +887,7 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
|
|||
assert.True(t, ok)
|
||||
|
||||
// now remove user1 after an update
|
||||
user1, _, err = httpd.UpdateUser(user1, http.StatusOK, "")
|
||||
err = dataprovider.UpdateUser(user1)
|
||||
assert.NoError(t, err)
|
||||
_, ok = dataprovider.GetCachedWebDAVUser(user1.Username)
|
||||
assert.False(t, ok)
|
||||
|
@ -905,13 +914,13 @@ func TestUsersCacheSizeAndExpiration(t *testing.T) {
|
|||
_, ok = dataprovider.GetCachedWebDAVUser(user4.Username)
|
||||
assert.True(t, ok)
|
||||
|
||||
_, err = httpd.RemoveUser(user1, http.StatusOK)
|
||||
err = dataprovider.DeleteUser(user1)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpd.RemoveUser(user2, http.StatusOK)
|
||||
err = dataprovider.DeleteUser(user2)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpd.RemoveUser(user3, http.StatusOK)
|
||||
err = dataprovider.DeleteUser(user3)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpd.RemoveUser(user4, http.StatusOK)
|
||||
err = dataprovider.DeleteUser(user4)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ var (
|
|||
type webDavServer struct {
|
||||
config *Configuration
|
||||
certMgr *common.CertManager
|
||||
status ServiceStatus
|
||||
}
|
||||
|
||||
func newServer(config *Configuration, configDir string) (*webDavServer, error) {
|
||||
|
@ -53,8 +54,12 @@ func newServer(config *Configuration, configDir string) (*webDavServer, error) {
|
|||
}
|
||||
|
||||
func (s *webDavServer) listenAndServe() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.BindAddress, s.config.BindPort)
|
||||
s.status.IsActive = true
|
||||
s.status.Address = addr
|
||||
s.status.Protocol = "HTTP"
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.config.BindAddress, s.config.BindPort),
|
||||
Addr: addr,
|
||||
Handler: server,
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
|
@ -75,6 +80,7 @@ func (s *webDavServer) listenAndServe() error {
|
|||
httpServer.Handler = server
|
||||
}
|
||||
if s.certMgr != nil {
|
||||
s.status.Protocol = "HTTPS"
|
||||
httpServer.TLSConfig = &tls.Config{
|
||||
GetCertificate: s.certMgr.GetCertificateFunc(),
|
||||
MinVersion: tls.VersionTLS12,
|
||||
|
|
|
@ -23,6 +23,13 @@ var (
|
|||
server *webDavServer
|
||||
)
|
||||
|
||||
// ServiceStatus defines the service status
|
||||
type ServiceStatus struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol"`
|
||||
}
|
||||
|
||||
// Cors configuration
|
||||
type Cors struct {
|
||||
AllowedOrigins []string `json:"allowed_origins" mapstructure:"allowed_origins"`
|
||||
|
@ -70,7 +77,15 @@ type Configuration struct {
|
|||
Cache Cache `json:"cache" mapstructure:"cache"`
|
||||
}
|
||||
|
||||
// Initialize configures and starts the WebDav server
|
||||
// GetStatus returns the server status
|
||||
func GetStatus() ServiceStatus {
|
||||
if server == nil {
|
||||
return ServiceStatus{}
|
||||
}
|
||||
return server.status
|
||||
}
|
||||
|
||||
// Initialize configures and starts the WebDAV server
|
||||
func (c *Configuration) Initialize(configDir string) error {
|
||||
var err error
|
||||
logger.Debug(logSender, "", "initializing WebDAV server with config %+v", *c)
|
||||
|
|
|
@ -157,6 +157,12 @@ func TestMain(m *testing.M) {
|
|||
AllowCredentials: true,
|
||||
}
|
||||
|
||||
status := webdavd.GetStatus()
|
||||
if status.IsActive {
|
||||
logger.ErrorToConsole("webdav server is already active")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
|
||||
preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
|
||||
postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
|
||||
|
@ -284,6 +290,8 @@ func TestBasicHandling(t *testing.T) {
|
|||
err = os.RemoveAll(user.GetHomeDir())
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, common.Connections.GetStats(), 0)
|
||||
status := webdavd.GetStatus()
|
||||
assert.True(t, status.IsActive)
|
||||
}
|
||||
|
||||
func TestBasicHandlingCryptFs(t *testing.T) {
|
||||
|
|
Loading…
Reference in a new issue