mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-25 00:50:31 +00:00
Add support for allowed/denied IP/Mask
Login can be restricted to specific ranges of IP address or to a specific IP address. Please apply the appropriate SQL upgrade script to add the filter field to your database. The filter database field will allow to add other filters without requiring a new database migration
This commit is contained in:
parent
ad5436e3f6
commit
1b1c740b29
22 changed files with 623 additions and 95 deletions
|
@ -11,7 +11,7 @@ env:
|
|||
- GO111MODULE=on
|
||||
|
||||
before_script:
|
||||
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);'
|
||||
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" TEXT NULL);'
|
||||
|
||||
install:
|
||||
- go get -v -t ./...
|
||||
|
|
|
@ -14,6 +14,7 @@ Full featured and highly configurable SFTP server
|
|||
- Per user maximum concurrent sessions.
|
||||
- Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled.
|
||||
- Per user files/folders ownership: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only).
|
||||
- Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
|
||||
- Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
|
||||
- Automatically terminating idle connections.
|
||||
- Atomic uploads are configurable.
|
||||
|
@ -388,6 +389,8 @@ For each account the following properties can be configured:
|
|||
- `chtimes` changing file or directory access and modification time is allowed
|
||||
- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited.
|
||||
- `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited.
|
||||
- `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
|
||||
- `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied
|
||||
|
||||
These properties are stored inside the data provider. If you want to use your existing accounts, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view.
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -386,6 +387,41 @@ func validatePermissions(user *User) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validatePublicKeys(user *User) error {
|
||||
if len(user.PublicKeys) == 0 {
|
||||
user.PublicKeys = []string{}
|
||||
}
|
||||
for i, k := range user.PublicKeys {
|
||||
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFilters(user *User) error {
|
||||
if len(user.Filters.AllowedIP) == 0 {
|
||||
user.Filters.AllowedIP = []string{}
|
||||
}
|
||||
if len(user.Filters.DeniedIP) == 0 {
|
||||
user.Filters.DeniedIP = []string{}
|
||||
}
|
||||
for _, IPMask := range user.Filters.DeniedIP {
|
||||
_, _, err := net.ParseCIDR(IPMask)
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could not parse denied IP/Mask %#v : %v", IPMask, err)}
|
||||
}
|
||||
}
|
||||
for _, IPMask := range user.Filters.AllowedIP {
|
||||
_, _, err := net.ParseCIDR(IPMask)
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could not parse allowed IP/Mask %#v : %v", IPMask, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUser(user *User) error {
|
||||
buildUserHomeDir(user)
|
||||
if len(user.Username) == 0 || len(user.HomeDir) == 0 {
|
||||
|
@ -410,14 +446,11 @@ func validateUser(user *User) error {
|
|||
}
|
||||
user.Password = pwd
|
||||
}
|
||||
if len(user.PublicKeys) == 0 {
|
||||
user.PublicKeys = []string{}
|
||||
}
|
||||
for i, k := range user.PublicKeys {
|
||||
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k))
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could not parse key nr. %d: %s", i, err)}
|
||||
if err := validatePublicKeys(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFilters(user); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -158,8 +158,12 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filters, err := user.GetFiltersAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate)
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters))
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -183,8 +187,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filters, err := user.GetFiltersAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, user.ID)
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
|
||||
string(filters), user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -262,16 +271,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
var permissions sql.NullString
|
||||
var password sql.NullString
|
||||
var publicKey sql.NullString
|
||||
var filters sql.NullString
|
||||
var err error
|
||||
if row != nil {
|
||||
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
|
||||
|
||||
} else {
|
||||
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
|
||||
}
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
@ -304,5 +314,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if filters.Valid {
|
||||
var userFilters UserFilters
|
||||
err = json.Unmarshal([]byte(filters.String), &userFilters)
|
||||
if err == nil {
|
||||
user.Filters = userFilters
|
||||
}
|
||||
} else {
|
||||
user.Filters = UserFilters{
|
||||
AllowedIP: []string{},
|
||||
DeniedIP: []string{},
|
||||
}
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ package dataprovider
|
|||
import "fmt"
|
||||
|
||||
const (
|
||||
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," +
|
||||
"used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status"
|
||||
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
|
||||
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters"
|
||||
)
|
||||
|
||||
func getSQLPlaceholders() []string {
|
||||
|
@ -60,18 +60,19 @@ func getQuotaQuery() string {
|
|||
|
||||
func getAddUserQuery() string {
|
||||
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
|
||||
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
|
||||
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13])
|
||||
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
|
||||
sqlPlaceholders[14])
|
||||
}
|
||||
|
||||
func getUpdateUserQuery() string {
|
||||
return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v WHERE id = %v`, config.UsersTable,
|
||||
sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5],
|
||||
sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11],
|
||||
sqlPlaceholders[12], sqlPlaceholders[13])
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v WHERE id = %v`,
|
||||
config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
|
||||
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10],
|
||||
sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14])
|
||||
}
|
||||
|
||||
func getDeleteUserQuery() string {
|
||||
|
|
|
@ -3,11 +3,13 @@ package dataprovider
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
)
|
||||
|
||||
|
@ -40,6 +42,17 @@ const (
|
|||
PermChtimes = "chtimes"
|
||||
)
|
||||
|
||||
// UserFilters defines additional restrictions for a user
|
||||
type UserFilters struct {
|
||||
// only clients connecting from these IP/Mask are allowed.
|
||||
// IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291
|
||||
// for example "192.0.2.0/24" or "2001:db8::/32"
|
||||
AllowedIP []string `json:"allowed_ip"`
|
||||
// clients connecting from these IP/Mask are not allowed.
|
||||
// Denied rules will be evaluated before allowed ones
|
||||
DeniedIP []string `json:"denied_ip"`
|
||||
}
|
||||
|
||||
// User defines an SFTP user
|
||||
type User struct {
|
||||
// Database unique identifier
|
||||
|
@ -83,6 +96,8 @@ type User struct {
|
|||
DownloadBandwidth int64 `json:"download_bandwidth"`
|
||||
// Last login as unix timestamp in milliseconds
|
||||
LastLogin int64 `json:"last_login"`
|
||||
// Additional restrictions
|
||||
Filters UserFilters `json:"filters"`
|
||||
}
|
||||
|
||||
// GetPermissionsForPath returns the permissions for the given path
|
||||
|
@ -144,6 +159,41 @@ func (u *User) HasPerms(permissions []string, path string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// IsLoginAllowed return true if the login is allowed from the specified remoteAddr.
|
||||
// If AllowedIP is defined only the specified IP/Mask can login.
|
||||
// If DeniedIP is defined the specified IP/Mask cannot login.
|
||||
// If an IP is both allowed and denied then login will be denied
|
||||
func (u *User) IsLoginAllowed(remoteAddr string) bool {
|
||||
if len(u.Filters.AllowedIP) == 0 && len(u.Filters.DeniedIP) == 0 {
|
||||
return true
|
||||
}
|
||||
remoteIP := net.ParseIP(utils.GetIPFromRemoteAddress(remoteAddr))
|
||||
// if remoteIP is invalid we allow login, this should never happen
|
||||
if remoteIP == nil {
|
||||
logger.Warn(logSender, "", "login allowed for invalid IP. remote address: %#v", remoteAddr)
|
||||
return true
|
||||
}
|
||||
for _, IPMask := range u.Filters.DeniedIP {
|
||||
_, IPNet, err := net.ParseCIDR(IPMask)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if IPNet.Contains(remoteIP) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, IPMask := range u.Filters.AllowedIP {
|
||||
_, IPNet, err := net.ParseCIDR(IPMask)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if IPNet.Contains(remoteIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return len(u.Filters.AllowedIP) == 0
|
||||
}
|
||||
|
||||
// GetPermissionsAsJSON returns the permissions as json byte array
|
||||
func (u *User) GetPermissionsAsJSON() ([]byte, error) {
|
||||
return json.Marshal(u.Permissions)
|
||||
|
@ -154,6 +204,11 @@ func (u *User) GetPublicKeysAsJSON() ([]byte, error) {
|
|||
return json.Marshal(u.PublicKeys)
|
||||
}
|
||||
|
||||
// GetFiltersAsJSON returns the filters as json byte array
|
||||
func (u *User) GetFiltersAsJSON() ([]byte, error) {
|
||||
return json.Marshal(u.Filters)
|
||||
}
|
||||
|
||||
// GetUID returns a validate uid, suitable for use with os.Chown
|
||||
func (u *User) GetUID() int {
|
||||
if u.UID <= 0 || u.UID > 65535 {
|
||||
|
@ -274,6 +329,12 @@ func (u *User) GetInfoString() string {
|
|||
if u.GID > 0 {
|
||||
result += fmt.Sprintf("GID: %v ", u.GID)
|
||||
}
|
||||
if len(u.Filters.DeniedIP) > 0 {
|
||||
result += fmt.Sprintf("Denied IP/Mask: %v ", len(u.Filters.DeniedIP))
|
||||
}
|
||||
if len(u.Filters.AllowedIP) > 0 {
|
||||
result += fmt.Sprintf("Allowed IP/Mask: %v ", len(u.Filters.AllowedIP))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
@ -286,6 +347,30 @@ func (u *User) GetExpirationDateAsString() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// GetAllowedIPAsString returns the allowed IP as comma separated string
|
||||
func (u User) GetAllowedIPAsString() string {
|
||||
result := ""
|
||||
for _, IPMask := range u.Filters.AllowedIP {
|
||||
if len(result) > 0 {
|
||||
result += ","
|
||||
}
|
||||
result += IPMask
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetDeniedIPAsString returns the denied IP as comma separated string
|
||||
func (u User) GetDeniedIPAsString() string {
|
||||
result := ""
|
||||
for _, IPMask := range u.Filters.DeniedIP {
|
||||
if len(result) > 0 {
|
||||
result += ","
|
||||
}
|
||||
result += IPMask
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *User) getACopy() User {
|
||||
pubKeys := make([]string, len(u.PublicKeys))
|
||||
copy(pubKeys, u.PublicKeys)
|
||||
|
@ -295,6 +380,12 @@ func (u *User) getACopy() User {
|
|||
copy(perms, v)
|
||||
permissions[k] = perms
|
||||
}
|
||||
filters := UserFilters{}
|
||||
filters.AllowedIP = make([]string, len(u.Filters.AllowedIP))
|
||||
copy(filters.AllowedIP, u.Filters.AllowedIP)
|
||||
filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
|
||||
copy(filters.DeniedIP, u.Filters.DeniedIP)
|
||||
|
||||
return User{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
|
@ -315,6 +406,7 @@ func (u *User) getACopy() User {
|
|||
Status: u.Status,
|
||||
ExpirationDate: u.ExpirationDate,
|
||||
LastLogin: u.LastLogin,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ sudo groupadd -g 1003 sftpgrp && \
|
|||
sudo -u sftpuser mkdir /home/sftpuser/{conf,data} && \
|
||||
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20190828.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
|
||||
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191112.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
|
||||
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sql/sqlite/20191230.sql | sqlite3 /home/sftpuser/conf/sftpgo.db && \
|
||||
curl https://raw.githubusercontent.com/drakkan/sftpgo/master/sftpgo.json -o /home/sftpuser/conf/sftpgo.json
|
||||
|
||||
# Get and build SFTPGo image
|
||||
|
|
|
@ -97,7 +97,7 @@ func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User,
|
|||
body, _ = getResponseBody(resp)
|
||||
}
|
||||
if err == nil {
|
||||
err = checkUser(user, newUser)
|
||||
err = checkUser(&user, &newUser)
|
||||
}
|
||||
return newUser, body, err
|
||||
}
|
||||
|
@ -129,7 +129,7 @@ func UpdateUser(user dataprovider.User, expectedStatusCode int) (dataprovider.Us
|
|||
newUser, body, err = GetUserByID(user.ID, expectedStatusCode)
|
||||
}
|
||||
if err == nil {
|
||||
err = checkUser(user, newUser)
|
||||
err = checkUser(&user, &newUser)
|
||||
}
|
||||
return newUser, body, err
|
||||
}
|
||||
|
@ -376,7 +376,7 @@ func getResponseBody(resp *http.Response) ([]byte, error) {
|
|||
return ioutil.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func checkUser(expected dataprovider.User, actual dataprovider.User) error {
|
||||
func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if len(actual.Password) > 0 {
|
||||
return errors.New("User password must not be visible")
|
||||
}
|
||||
|
@ -389,6 +389,9 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error {
|
|||
return errors.New("user ID mismatch")
|
||||
}
|
||||
}
|
||||
if len(expected.Permissions) != len(actual.Permissions) {
|
||||
return errors.New("Permissions mismatch")
|
||||
}
|
||||
for dir, perms := range expected.Permissions {
|
||||
if actualPerms, ok := actual.Permissions[dir]; ok {
|
||||
for _, v := range actualPerms {
|
||||
|
@ -400,10 +403,34 @@ func checkUser(expected dataprovider.User, actual dataprovider.User) error {
|
|||
return errors.New("Permissions directories mismatch")
|
||||
}
|
||||
}
|
||||
if err := compareUserFilters(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return compareEqualsUserFields(expected, actual)
|
||||
}
|
||||
|
||||
func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.User) error {
|
||||
func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) {
|
||||
return errors.New("AllowedIP mismatch")
|
||||
}
|
||||
if len(expected.Filters.DeniedIP) != len(actual.Filters.DeniedIP) {
|
||||
return errors.New("DeniedIP mismatch")
|
||||
}
|
||||
for _, IPMask := range expected.Filters.AllowedIP {
|
||||
if !utils.IsStringInSlice(IPMask, actual.Filters.AllowedIP) {
|
||||
return errors.New("AllowedIP contents mismatch")
|
||||
}
|
||||
}
|
||||
for _, IPMask := range expected.Filters.DeniedIP {
|
||||
if !utils.IsStringInSlice(IPMask, actual.Filters.DeniedIP) {
|
||||
return errors.New("DeniedIP contents mismatch")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if expected.Username != actual.Username {
|
||||
return errors.New("Username mismatch")
|
||||
}
|
||||
|
|
|
@ -217,13 +217,28 @@ func TestAddUserInvalidPerms(t *testing.T) {
|
|||
u.Permissions["/"] = []string{"invalidPerm"}
|
||||
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with no perms: %v", err)
|
||||
t.Errorf("unexpected error adding user with invalid perms: %v", err)
|
||||
}
|
||||
// permissions for root dir are mandatory
|
||||
u.Permissions["/somedir"] = []string{dataprovider.PermAny}
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with no perms: %v", err)
|
||||
t.Errorf("unexpected error adding user with no root dir perms: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserInvalidFilters(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0"}
|
||||
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid filters: %v", err)
|
||||
}
|
||||
u.Filters.AllowedIP = []string{}
|
||||
u.Filters.DeniedIP = []string{"192.168.3.0/16", "invalid"}
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid filters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,6 +285,8 @@ func TestUpdateUser(t *testing.T) {
|
|||
user.QuotaFiles = 2
|
||||
user.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
|
||||
user.Permissions["/subdir"] = []string{dataprovider.PermListItems, dataprovider.PermUpload}
|
||||
user.Filters.AllowedIP = []string{"192.168.1.0/24", "192.168.2.0/24"}
|
||||
user.Filters.DeniedIP = []string{"192.168.3.0/24", "192.168.4.0/24"}
|
||||
user.UploadBandwidth = 1024
|
||||
user.DownloadBandwidth = 512
|
||||
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
|
@ -1010,6 +1027,7 @@ func TestStartQuotaScanMock(t *testing.T) {
|
|||
t.Errorf("Error get active scans: %v", err)
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_, err = os.Stat(user.HomeDir)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
|
@ -1018,6 +1036,26 @@ func TestStartQuotaScanMock(t *testing.T) {
|
|||
req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusCreated, rr.Code)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
err = render.DecodeJSON(rr.Body, &scans)
|
||||
if err != nil {
|
||||
t.Errorf("Error get active scans: %v", err)
|
||||
}
|
||||
for len(scans) > 0 {
|
||||
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
err = render.DecodeJSON(rr.Body, &scans)
|
||||
if err != nil {
|
||||
t.Errorf("Error get active scans: %v", err)
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
|
@ -1222,6 +1260,20 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
form.Set("expiration_date", "")
|
||||
form.Set("allowed_ip", "invalid,ip")
|
||||
// test invalid allowed_ip
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
form.Set("allowed_ip", "")
|
||||
form.Set("denied_ip", "192.168.1.2") // it should be 192.168.1.2/32
|
||||
// test invalid denied_ip
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
form.Set("denied_ip", "")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = executeRequest(req)
|
||||
|
@ -1255,6 +1307,13 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
if !utils.IsStringInSlice(testPubKey, newUser.PublicKeys) {
|
||||
t.Errorf("public_keys does not match")
|
||||
}
|
||||
if val, ok := newUser.Permissions["/subdir"]; ok {
|
||||
if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermDownload, val) {
|
||||
t.Error("permssions for /subdir does not match")
|
||||
}
|
||||
} else {
|
||||
t.Errorf("user permissions must contains /somedir, actual: %v", newUser.Permissions)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
|
@ -1288,6 +1347,8 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
form.Set("sub_dirs_permissions", "/otherdir : list ,upload ")
|
||||
form.Set("status", strconv.Itoa(user.Status))
|
||||
form.Set("expiration_date", "2020-01-01 00:00:00")
|
||||
form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
|
||||
form.Set("denied_ip", " 10.0.0.2/32 ")
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = executeRequest(req)
|
||||
|
@ -1319,6 +1380,19 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
if user.GID != updateUser.GID {
|
||||
t.Errorf("gid does not match")
|
||||
}
|
||||
if val, ok := updateUser.Permissions["/otherdir"]; ok {
|
||||
if !utils.IsStringInSlice(dataprovider.PermListItems, val) || !utils.IsStringInSlice(dataprovider.PermUpload, val) {
|
||||
t.Error("permssions for /otherdir does not match")
|
||||
}
|
||||
} else {
|
||||
t.Errorf("user permissions must contains /otherdir, actual: %v", updateUser.Permissions)
|
||||
}
|
||||
if !utils.IsStringInSlice("192.168.1.3/32", updateUser.Filters.AllowedIP) {
|
||||
t.Errorf("Allowed IP/Mask does not match: %v", updateUser.Filters.AllowedIP)
|
||||
}
|
||||
if !utils.IsStringInSlice("10.0.0.2/32", updateUser.Filters.DeniedIP) {
|
||||
t.Errorf("Denied IP/Mask does not match: %v", updateUser.Filters.DeniedIP)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
|
|
|
@ -43,8 +43,8 @@ func TestCheckResponse(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCheckUser(t *testing.T) {
|
||||
expected := dataprovider.User{}
|
||||
actual := dataprovider.User{}
|
||||
expected := &dataprovider.User{}
|
||||
actual := &dataprovider.User{}
|
||||
actual.Password = "password"
|
||||
err := checkUser(expected, actual)
|
||||
if err == nil {
|
||||
|
@ -72,6 +72,10 @@ func TestCheckUser(t *testing.T) {
|
|||
expected.Permissions = make(map[string][]string)
|
||||
expected.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermDelete, dataprovider.PermDownload}
|
||||
actual.Permissions = make(map[string][]string)
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("Permissions are not equal")
|
||||
}
|
||||
actual.Permissions["/"] = []string{dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks}
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
|
@ -90,11 +94,37 @@ func TestCheckUser(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Errorf("Permissions are not equal")
|
||||
}
|
||||
expected.Permissions = make(map[string][]string)
|
||||
actual.Permissions = make(map[string][]string)
|
||||
expected.Filters.AllowedIP = []string{}
|
||||
actual.Filters.AllowedIP = []string{"192.168.1.2/32"}
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("AllowedIP are not equal")
|
||||
}
|
||||
expected.Filters.AllowedIP = []string{"192.168.1.3/32"}
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("AllowedIP contents are not equal")
|
||||
}
|
||||
expected.Filters.AllowedIP = []string{}
|
||||
actual.Filters.AllowedIP = []string{}
|
||||
expected.Filters.DeniedIP = []string{}
|
||||
actual.Filters.DeniedIP = []string{"192.168.1.2/32"}
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("DeniedIP are not equal")
|
||||
}
|
||||
expected.Filters.DeniedIP = []string{"192.168.1.3/32"}
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("DeniedIP contents are not equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareUserFields(t *testing.T) {
|
||||
expected := dataprovider.User{}
|
||||
actual := dataprovider.User{}
|
||||
expected := &dataprovider.User{}
|
||||
actual := &dataprovider.User{}
|
||||
expected.Permissions = make(map[string][]string)
|
||||
actual.Permissions = make(map[string][]string)
|
||||
expected.Username = "test"
|
||||
|
|
|
@ -2,7 +2,7 @@ openapi: 3.0.1
|
|||
info:
|
||||
title: SFTPGo
|
||||
description: 'SFTPGo REST API'
|
||||
version: 1.4.0
|
||||
version: 1.5.0
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
@ -693,6 +693,23 @@ components:
|
|||
minItems: 1
|
||||
minProperties: 1
|
||||
description: hash map with directory as key and an array of permissions as value. Directories must be absolute paths, permissions for root directory ("/") are required
|
||||
UserFilters:
|
||||
type: object
|
||||
properties:
|
||||
allowed_ip:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: only clients connecting from these IP/Mask are allowed. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
|
||||
example: [ "192.0.2.0/24", "2001:db8::/32" ]
|
||||
denied_ip:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
description: clients connecting from these IP/Mask are not allowed. Denied rules are evaluated before allowed ones
|
||||
example: [ "172.16.0.0/16" ]
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -743,15 +760,15 @@ components:
|
|||
max_sessions:
|
||||
type: integer
|
||||
format: int32
|
||||
description: limit the sessions that an user can open. 0 means unlimited
|
||||
description: Limit the sessions that an user can open. 0 means unlimited
|
||||
quota_size:
|
||||
type: integer
|
||||
format: int64
|
||||
description: quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
|
||||
description: Quota as size in bytes. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
|
||||
quota_files:
|
||||
type: integer
|
||||
format: int32
|
||||
description: quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
|
||||
description: Quota as number of files. 0 menas unlimited. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed
|
||||
permissions:
|
||||
type: object
|
||||
items:
|
||||
|
@ -767,7 +784,7 @@ components:
|
|||
last_quota_update:
|
||||
type: integer
|
||||
format: int64
|
||||
description: last quota update as unix timestamp in milliseconds
|
||||
description: Last quota update as unix timestamp in milliseconds
|
||||
upload_bandwidth:
|
||||
type: integer
|
||||
format: int32
|
||||
|
@ -779,7 +796,11 @@ components:
|
|||
last_login:
|
||||
type: integer
|
||||
format: int64
|
||||
description: last user login as unix timestamp in milliseconds
|
||||
description: Last user login as unix timestamp in milliseconds
|
||||
filters:
|
||||
$ref: '#/components/schemas/UserFilters'
|
||||
nullable: true
|
||||
description: Additional restrictions
|
||||
Transfer:
|
||||
type: object
|
||||
properties:
|
||||
|
|
33
httpd/web.go
33
httpd/web.go
|
@ -184,12 +184,12 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
|
|||
permissions := make(map[string][]string)
|
||||
permissions["/"] = r.Form["permissions"]
|
||||
subDirsPermsValue := r.Form.Get("sub_dirs_permissions")
|
||||
for _, v := range strings.Split(subDirsPermsValue, "\n") {
|
||||
cleaned := strings.TrimSpace(v)
|
||||
if len(cleaned) > 0 && strings.ContainsRune(cleaned, ':') {
|
||||
for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") {
|
||||
if strings.ContainsRune(cleaned, ':') {
|
||||
dirPerms := strings.Split(cleaned, ":")
|
||||
if len(dirPerms) > 1 {
|
||||
dir := dirPerms[0]
|
||||
dir = strings.TrimSpace(dir)
|
||||
perms := []string{}
|
||||
for _, p := range strings.Split(dirPerms[1], ",") {
|
||||
cleanedPerm := strings.TrimSpace(p)
|
||||
|
@ -206,6 +206,24 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
|
|||
return permissions
|
||||
}
|
||||
|
||||
func getSliceFromDelimitedValues(values, delimiter string) []string {
|
||||
result := []string{}
|
||||
for _, v := range strings.Split(values, delimiter) {
|
||||
cleaned := strings.TrimSpace(v)
|
||||
if len(cleaned) > 0 {
|
||||
result = append(result, cleaned)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
|
||||
var filters dataprovider.UserFilters
|
||||
filters.AllowedIP = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
|
||||
filters.DeniedIP = getSliceFromDelimitedValues(r.Form.Get("denied_ip"), ",")
|
||||
return filters
|
||||
}
|
||||
|
||||
func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||
var user dataprovider.User
|
||||
err := r.ParseForm()
|
||||
|
@ -213,13 +231,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
|||
return user, err
|
||||
}
|
||||
publicKeysFormValue := r.Form.Get("public_keys")
|
||||
publicKeys := []string{}
|
||||
for _, v := range strings.Split(publicKeysFormValue, "\n") {
|
||||
cleaned := strings.TrimSpace(v)
|
||||
if len(cleaned) > 0 {
|
||||
publicKeys = append(publicKeys, cleaned)
|
||||
}
|
||||
}
|
||||
publicKeys := getSliceFromDelimitedValues(publicKeysFormValue, "\n")
|
||||
uid, err := strconv.Atoi(r.Form.Get("uid"))
|
||||
if err != nil {
|
||||
return user, err
|
||||
|
@ -276,6 +288,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
|||
DownloadBandwidth: bandwidthDL,
|
||||
Status: status,
|
||||
ExpirationDate: expirationDateMillis,
|
||||
Filters: getFiltersFromUserPostFields(r),
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ Let's see a sample usage for each REST API.
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -50,6 +50,12 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 60,
|
||||
"expiration_date": 1546297200000,
|
||||
"filters": {
|
||||
"allowed_ip": [
|
||||
"192.168.1.1/32"
|
||||
],
|
||||
"denied_ip": []
|
||||
},
|
||||
"gid": 1000,
|
||||
"home_dir": "/tmp/test_home_dir",
|
||||
"id": 9576,
|
||||
|
@ -90,7 +96,7 @@ Output:
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date ""
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -117,6 +123,12 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 80,
|
||||
"expiration_date": 0,
|
||||
"filters": {
|
||||
"allowed_ip": [],
|
||||
"denied_ip": [
|
||||
"192.168.1.0/24"
|
||||
]
|
||||
},
|
||||
"gid": 33,
|
||||
"home_dir": "/tmp/test_home_dir",
|
||||
"id": 9576,
|
||||
|
@ -159,6 +171,12 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 80,
|
||||
"expiration_date": 0,
|
||||
"filters": {
|
||||
"allowed_ip": [],
|
||||
"denied_ip": [
|
||||
"192.168.1.0/24"
|
||||
]
|
||||
},
|
||||
"gid": 33,
|
||||
"home_dir": "/tmp/test_home_dir",
|
||||
"id": 9576,
|
||||
|
|
|
@ -70,9 +70,9 @@ class SFTPGoApiRequests:
|
|||
else:
|
||||
print(r.text)
|
||||
|
||||
def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0,
|
||||
def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0,
|
||||
gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0,
|
||||
download_bandwidth=0, status=1, expiration_date=0):
|
||||
download_bandwidth=0, status=1, expiration_date=0, allowed_ip=[], denied_ip=[]):
|
||||
user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
|
||||
"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
|
||||
"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth,
|
||||
|
@ -88,6 +88,8 @@ class SFTPGoApiRequests:
|
|||
user.update({"home_dir":home_dir})
|
||||
if permissions:
|
||||
user.update({"permissions":permissions})
|
||||
if allowed_ip or denied_ip:
|
||||
user.update({"filters":self.buildFilters(allowed_ip, denied_ip)})
|
||||
return user
|
||||
|
||||
def buildPermissions(self, root_perms, subdirs_perms):
|
||||
|
@ -107,6 +109,20 @@ class SFTPGoApiRequests:
|
|||
permissions.update({directory:values})
|
||||
return permissions
|
||||
|
||||
def buildFilters(self, allowed_ip, denied_ip):
|
||||
filters = {}
|
||||
if allowed_ip:
|
||||
if len(allowed_ip) == 1 and not allowed_ip[0]:
|
||||
filters.update({"allowed_ip":[]})
|
||||
else:
|
||||
filters.update({"allowed_ip":allowed_ip})
|
||||
if denied_ip:
|
||||
if len(denied_ip) == 1 and not denied_ip[0]:
|
||||
filters.update({"denied_ip":[]})
|
||||
else:
|
||||
filters.update({"denied_ip":denied_ip})
|
||||
return filters
|
||||
|
||||
def getUsers(self, limit=100, offset=0, order="ASC", username=""):
|
||||
r = requests.get(self.userPath, params={"limit":limit, "offset":offset, "order":order,
|
||||
"username":username}, auth=self.auth, verify=self.verify)
|
||||
|
@ -118,19 +134,20 @@ class SFTPGoApiRequests:
|
|||
|
||||
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
|
||||
quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
|
||||
expiration_date=0, subdirs_permissions=[]):
|
||||
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[]):
|
||||
u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date)
|
||||
status, expiration_date, allowed_ip, denied_ip)
|
||||
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0,
|
||||
max_sessions=0, quota_size=0, quota_files=0, perms=[], upload_bandwidth=0,
|
||||
download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[]):
|
||||
download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[],
|
||||
allowed_ip=[], denied_ip=[]):
|
||||
u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date)
|
||||
status, expiration_date, allowed_ip, denied_ip)
|
||||
r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
|
@ -251,7 +268,7 @@ class ConvertUsers:
|
|||
user_info = spwd.getspnam(username)
|
||||
password = user_info.sp_pwdp
|
||||
if not password or password == '!!':
|
||||
print('cannot import user "{}" without password'.format(username))
|
||||
print('cannot import user "{}" without a password'.format(username))
|
||||
continue
|
||||
if user_info.sp_inact > 0:
|
||||
last_pwd_change_diff = days_from_epoch_time - user_info.sp_lstchg
|
||||
|
@ -283,11 +300,27 @@ class ConvertUsers:
|
|||
self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid, 0, 0,
|
||||
0, permissions, 0, 0, 1, 0))
|
||||
|
||||
def convertPureFTPDIP(self, fields):
|
||||
result = []
|
||||
if not fields:
|
||||
return result
|
||||
for v in fields.split(","):
|
||||
ip_mask = v.strip()
|
||||
if not ip_mask:
|
||||
continue
|
||||
if ip_mask.count(".") < 3 and ip_mask.count(":") < 3:
|
||||
print("cannot import pure-ftpd IP: {}".format(ip_mask))
|
||||
continue
|
||||
if "/" not in ip_mask:
|
||||
ip_mask += "/32"
|
||||
result.append(ip_mask)
|
||||
return result
|
||||
|
||||
def convertFromPureFTPD(self):
|
||||
with open(self.input_file, 'r') as f:
|
||||
for line in f:
|
||||
fields = line.split(':')
|
||||
if len(fields) > 13:
|
||||
if len(fields) > 16:
|
||||
username = fields[0]
|
||||
password = fields[1]
|
||||
uid = int(fields[2])
|
||||
|
@ -308,6 +341,8 @@ class ConvertUsers:
|
|||
quota_size = 0
|
||||
if fields[12]:
|
||||
quota_size = int(fields[12])
|
||||
allowed_ip = self.convertPureFTPDIP(fields[15])
|
||||
denied_ip = self.convertPureFTPDIP(fields[16])
|
||||
if not self.isUserValid(username, uid, gid):
|
||||
continue
|
||||
if self.force_uid >= 0:
|
||||
|
@ -317,7 +352,8 @@ class ConvertUsers:
|
|||
permissions = self.SFTPGoRestAPI.buildPermissions(['*'], [])
|
||||
self.addUser(self.SFTPGoRestAPI.buildUserObject(0, username, password, [], home_dir, uid, gid,
|
||||
max_sessions, quota_size, quota_files, permissions,
|
||||
upload_bandwidth, download_bandwidth, 1, 0))
|
||||
upload_bandwidth, download_bandwidth, 1, 0, allowed_ip,
|
||||
denied_ip))
|
||||
|
||||
|
||||
def validDate(s):
|
||||
|
@ -361,6 +397,10 @@ def addCommonUserArguments(parser):
|
|||
help='User\'s status. 1 enabled, 0 disabled. Default: %(default)s')
|
||||
parser.add_argument('-E', '--expiration-date', type=validDate, default="",
|
||||
help='Expiration date as YYYY-MM-DD, empty string means no expiration. Default: %(default)s')
|
||||
parser.add_argument('-Y', '--allowed-ip', type=str, nargs='+', default=[],
|
||||
help='Allowed IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
|
||||
parser.add_argument('-N', '--denied-ip', type=str, nargs='+', default=[],
|
||||
help='Denied IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -458,12 +498,13 @@ if __name__ == '__main__':
|
|||
if args.command == 'add-user':
|
||||
api.addUser(args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions,
|
||||
args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth,
|
||||
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions)
|
||||
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip,
|
||||
args.denied_ip)
|
||||
elif args.command == 'update-user':
|
||||
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
|
||||
args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
|
||||
args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date),
|
||||
args.subdirs_permissions)
|
||||
args.subdirs_permissions, args.allowed_ip, args.denied_ip)
|
||||
elif args.command == 'delete-user':
|
||||
api.deleteUser(args.id)
|
||||
elif args.command == 'get-users':
|
||||
|
|
|
@ -174,7 +174,7 @@ func TestUploadFiles(t *testing.T) {
|
|||
func TestWithInvalidHome(t *testing.T) {
|
||||
u := dataprovider.User{}
|
||||
u.HomeDir = "home_rel_path"
|
||||
_, err := loginUser(u, "password")
|
||||
_, err := loginUser(u, "password", "")
|
||||
if err == nil {
|
||||
t.Errorf("login a user with an invalid home_dir must fail")
|
||||
}
|
||||
|
|
|
@ -352,12 +352,24 @@ func (c Configuration) createHandler(connection Connection) sftp.Handlers {
|
|||
}
|
||||
}
|
||||
|
||||
func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, error) {
|
||||
func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ssh.Permissions, error) {
|
||||
if !filepath.IsAbs(user.HomeDir) {
|
||||
logger.Warn(logSender, "", "user %#v has an invalid home dir: %#v. Home dir must be an absolute path, login not allowed",
|
||||
user.Username, user.HomeDir)
|
||||
return nil, fmt.Errorf("cannot login user with invalid home dir: %#v", user.HomeDir)
|
||||
}
|
||||
if user.MaxSessions > 0 {
|
||||
activeSessions := getActiveSessions(user.Username)
|
||||
if activeSessions >= user.MaxSessions {
|
||||
logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
|
||||
activeSessions, user.MaxSessions)
|
||||
return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
|
||||
}
|
||||
}
|
||||
if !user.IsLoginAllowed(remoteAddr) {
|
||||
logger.Debug(logSender, "", "cannot login user %#v, remote address is not allowed: %v", user.Username, remoteAddr)
|
||||
return nil, fmt.Errorf("Login is not allowed from this address: %v", remoteAddr)
|
||||
}
|
||||
if _, err := os.Stat(user.HomeDir); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(user.HomeDir, 0777)
|
||||
logger.Debug(logSender, "", "home directory %#v for user %#v does not exist, try to create, mkdir error: %v",
|
||||
|
@ -367,15 +379,6 @@ func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, erro
|
|||
}
|
||||
}
|
||||
|
||||
if user.MaxSessions > 0 {
|
||||
activeSessions := getActiveSessions(user.Username)
|
||||
if activeSessions >= user.MaxSessions {
|
||||
logger.Debug(logSender, "", "authentication refused for user: %#v, too many open sessions: %v/%v", user.Username,
|
||||
activeSessions, user.MaxSessions)
|
||||
return nil, fmt.Errorf("too many open sessions: %v", activeSessions)
|
||||
}
|
||||
}
|
||||
|
||||
json, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "error serializing user info: %v, authentication rejected", err)
|
||||
|
@ -432,7 +435,7 @@ func (c Configuration) validatePublicKeyCredentials(conn ssh.ConnMetadata, pubKe
|
|||
|
||||
metrics.AddLoginAttempt(true)
|
||||
if user, keyID, err = dataprovider.CheckUserAndPubKey(dataProvider, conn.User(), pubKey); err == nil {
|
||||
sshPerm, err = loginUser(user, "public_key:"+keyID)
|
||||
sshPerm, err = loginUser(user, "public_key:"+keyID, conn.RemoteAddr().String())
|
||||
} else {
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "public_key", err.Error())
|
||||
}
|
||||
|
@ -447,7 +450,7 @@ func (c Configuration) validatePasswordCredentials(conn ssh.ConnMetadata, pass [
|
|||
|
||||
metrics.AddLoginAttempt(false)
|
||||
if user, err = dataprovider.CheckUserAndPass(dataProvider, conn.User(), string(pass)); err == nil {
|
||||
sshPerm, err = loginUser(user, "password")
|
||||
sshPerm, err = loginUser(user, "password", conn.RemoteAddr().String())
|
||||
} else {
|
||||
logger.ConnectionFailedLog(conn.User(), utils.GetIPFromRemoteAddress(conn.RemoteAddr().String()), "password", err.Error())
|
||||
}
|
||||
|
|
|
@ -1014,6 +1014,60 @@ func TestLoginUserExpiration(t *testing.T) {
|
|||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginWithIPFilters(t *testing.T) {
|
||||
usePubKey := true
|
||||
u := getTestUser(usePubKey)
|
||||
u.Filters.DeniedIP = []string{"192.167.0.0/24", "172.18.0.0/16"}
|
||||
u.Filters.AllowedIP = []string{}
|
||||
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
client, err := getSftpClient(user, usePubKey)
|
||||
if err != nil {
|
||||
t.Errorf("unable to create sftp client: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
_, err := client.Getwd()
|
||||
if err != nil {
|
||||
t.Errorf("sftp client with valid credentials must work")
|
||||
}
|
||||
user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("error getting user: %v", err)
|
||||
}
|
||||
if user.LastLogin <= 0 {
|
||||
t.Errorf("last login must be updated after a successful login: %v", user.LastLogin)
|
||||
}
|
||||
}
|
||||
user.Filters.AllowedIP = []string{"127.0.0.0/8"}
|
||||
_, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to update user: %v", err)
|
||||
}
|
||||
client, err = getSftpClient(user, usePubKey)
|
||||
if err != nil {
|
||||
t.Errorf("login from an allowed IP must succeed: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
}
|
||||
user.Filters.AllowedIP = []string{"172.19.0.0/16"}
|
||||
_, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to update user: %v", err)
|
||||
}
|
||||
client, err = getSftpClient(user, usePubKey)
|
||||
if err == nil {
|
||||
t.Errorf("login from an not allowed IP must fail")
|
||||
client.Close()
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove user: %v", err)
|
||||
}
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
|
||||
usePubKey := false
|
||||
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||
|
@ -2581,6 +2635,60 @@ func TestUserPerms(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUserFiltersIPMaskConditions(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
// with no filter login must be allowed even if the remoteIP is invalid
|
||||
if !user.IsLoginAllowed("192.168.1.5") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
if !user.IsLoginAllowed("invalid") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
user.Filters.DeniedIP = append(user.Filters.DeniedIP, "192.168.1.0/24")
|
||||
if user.IsLoginAllowed("192.168.1.5") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
if !user.IsLoginAllowed("192.168.2.6") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
user.Filters.AllowedIP = append(user.Filters.AllowedIP, "192.168.1.5/32")
|
||||
// if the same ip/mask is both denied and allowed then login must be denied
|
||||
if user.IsLoginAllowed("192.168.1.5") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
if user.IsLoginAllowed("192.168.3.6") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
user.Filters.DeniedIP = []string{}
|
||||
if !user.IsLoginAllowed("192.168.1.5") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
if user.IsLoginAllowed("192.168.1.6") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
user.Filters.DeniedIP = []string{"192.168.0.0/16", "172.16.0.0/16"}
|
||||
user.Filters.AllowedIP = []string{}
|
||||
if user.IsLoginAllowed("192.168.5.255") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
if user.IsLoginAllowed("172.16.1.2") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
if !user.IsLoginAllowed("172.18.2.1") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
user.Filters.AllowedIP = []string{"10.4.4.0/24"}
|
||||
if user.IsLoginAllowed("10.5.4.2") {
|
||||
t.Error("unexpected login allowed")
|
||||
}
|
||||
if !user.IsLoginAllowed("10.4.4.2") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
if !user.IsLoginAllowed("invalid") {
|
||||
t.Error("unexpected login denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommands(t *testing.T) {
|
||||
usePubKey := false
|
||||
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||
|
@ -2739,8 +2847,6 @@ func TestBasicGitCommands(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Errorf("unexpected error: %v out: %v", err, string(out))
|
||||
printLatestLogs(10)
|
||||
out, err = pushToGitRepo(clonePath)
|
||||
logger.DebugToConsole("new push out: %v, err: %v", string(out), err)
|
||||
}
|
||||
err = waitQuotaScans()
|
||||
if err != nil {
|
||||
|
|
|
@ -261,7 +261,7 @@ func (c *sshCommand) executeSystemCommand(command systemCommand) error {
|
|||
w, e := transfer.copyFromReaderToWriter(c.connection.channel.Stderr(), stderr, 0)
|
||||
c.connection.Log(logger.LevelDebug, logSenderSSH, "command: %#v, copy from sdterr to remote command ended, written: %v err: %v",
|
||||
c.connection.command, w, e)
|
||||
// os.ErrClosed means that the command is finished so we don't need to to nothing
|
||||
// os.ErrClosed means that the command is finished so we don't need to do anything
|
||||
if (e != nil && !errors.Is(e, os.ErrClosed)) || w > 0 {
|
||||
once.Do(closeCmdOnError)
|
||||
}
|
||||
|
|
6
sql/mysql/20191230.sql
Normal file
6
sql/mysql/20191230.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filters to user
|
||||
--
|
||||
ALTER TABLE `users` ADD COLUMN `filters` longtext NULL;
|
||||
COMMIT;
|
6
sql/pgsql/20191230.sql
Normal file
6
sql/pgsql/20191230.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filters to user
|
||||
--
|
||||
ALTER TABLE "users" ADD COLUMN "filters" text NULL;
|
||||
COMMIT;
|
9
sql/sqlite/20191230.sql
Normal file
9
sql/sqlite/20191230.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filters to user
|
||||
--
|
||||
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "filters" text NULL, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);
|
||||
INSERT INTO "new__users" ("id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", "filters") SELECT "id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", NULL FROM "users";
|
||||
DROP TABLE "users";
|
||||
ALTER TABLE "new__users" RENAME TO "users";
|
||||
COMMIT;
|
|
@ -169,6 +169,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idDeniedIP" class="col-sm-2 col-form-label">Denied IP/Mask</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="idDeniedIP" name="denied_ip" placeholder=""
|
||||
value="{{.User.GetDeniedIPAsString}}" maxlength="255" aria-describedby="deniedIPHelpBlock">
|
||||
<small id="deniedIPHelpBlock" class="form-text text-muted">
|
||||
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="idAllowedIP" name="allowed_ip" placeholder=""
|
||||
value="{{.User.GetAllowedIPAsString}}" maxlength="255" aria-describedby="allowedIPHelpBlock">
|
||||
<small id="allowedIPHelpBlock" class="form-text text-muted">
|
||||
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
|
||||
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
|
||||
</form>
|
||||
|
|
Loading…
Reference in a new issue