2024-01-01 11:31:45 +01:00
|
|
|
// Copyright (C) 2019 Nicola Murino
|
2022-07-17 20:16:00 +02:00
|
|
|
//
|
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
|
|
|
// by the Free Software Foundation, version 3.
|
|
|
|
|
//
|
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
|
//
|
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
2023-01-03 10:18:30 +01:00
|
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
2022-07-17 20:16:00 +02:00
|
|
|
|
2019-11-18 23:30:37 +01:00
|
|
|
package sftpd
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/md5"
|
|
|
|
|
"crypto/sha1"
|
2021-04-05 18:23:40 +02:00
|
|
|
"crypto/sha256"
|
2019-11-18 23:30:37 +01:00
|
|
|
"crypto/sha512"
|
2019-11-26 22:26:42 +01:00
|
|
|
"errors"
|
2019-11-18 23:30:37 +01:00
|
|
|
"fmt"
|
|
|
|
|
"hash"
|
|
|
|
|
"io"
|
2020-10-31 11:02:04 +01:00
|
|
|
"runtime/debug"
|
2024-07-24 18:27:13 +02:00
|
|
|
"slices"
|
2019-11-18 23:30:37 +01:00
|
|
|
"strings"
|
2023-02-12 18:56:53 +01:00
|
|
|
"time"
|
2019-11-18 23:30:37 +01:00
|
|
|
|
2020-05-06 19:36:34 +02:00
|
|
|
"github.com/google/shlex"
|
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
|
|
2022-07-24 16:18:54 +02:00
|
|
|
"github.com/drakkan/sftpgo/v2/internal/common"
|
|
|
|
|
"github.com/drakkan/sftpgo/v2/internal/dataprovider"
|
|
|
|
|
"github.com/drakkan/sftpgo/v2/internal/logger"
|
|
|
|
|
"github.com/drakkan/sftpgo/v2/internal/metric"
|
|
|
|
|
"github.com/drakkan/sftpgo/v2/internal/util"
|
|
|
|
|
"github.com/drakkan/sftpgo/v2/internal/vfs"
|
2019-11-18 23:30:37 +01:00
|
|
|
)
|
|
|
|
|
|
2020-07-24 23:39:38 +02:00
|
|
|
const (
|
|
|
|
|
scpCmdName = "scp"
|
|
|
|
|
sshCommandLogSender = "SSHCommand"
|
|
|
|
|
)
|
2020-04-30 14:23:55 +02:00
|
|
|
|
2019-11-18 23:30:37 +01:00
|
|
|
type sshCommand struct {
|
|
|
|
|
command string
|
|
|
|
|
args []string
|
2020-07-24 23:39:38 +02:00
|
|
|
connection *Connection
|
2023-02-12 18:56:53 +01:00
|
|
|
startTime time.Time
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
|
|
|
|
|
2020-09-18 10:52:53 +02:00
|
|
|
func processSSHCommand(payload []byte, connection *Connection, enabledSSHCommands []string) bool {
|
2019-11-18 23:30:37 +01:00
|
|
|
var msg sshSubsystemExecMsg
|
|
|
|
|
if err := ssh.Unmarshal(payload, &msg); err == nil {
|
2020-02-14 16:17:32 +01:00
|
|
|
name, args, err := parseCommandPayload(msg.Command)
|
2022-12-27 18:51:53 +01:00
|
|
|
connection.Log(logger.LevelDebug, "new ssh command: %q args: %v num args: %d user: %s, error: %v",
|
2020-02-14 16:17:32 +01:00
|
|
|
name, args, len(args), connection.User.Username, err)
|
2024-07-24 18:27:13 +02:00
|
|
|
if err == nil && slices.Contains(enabledSSHCommands, name) {
|
2020-02-14 16:17:32 +01:00
|
|
|
connection.command = msg.Command
|
2020-04-30 14:23:55 +02:00
|
|
|
if name == scpCmdName && len(args) >= 2 {
|
2020-07-24 23:39:38 +02:00
|
|
|
connection.SetProtocol(common.ProtocolSCP)
|
2019-11-18 23:30:37 +01:00
|
|
|
scpCommand := scpCommand{
|
|
|
|
|
sshCommand: sshCommand{
|
|
|
|
|
command: name,
|
2020-07-24 23:39:38 +02:00
|
|
|
connection: connection,
|
2023-02-12 18:56:53 +01:00
|
|
|
startTime: time.Now(),
|
2019-11-18 23:30:37 +01:00
|
|
|
args: args},
|
|
|
|
|
}
|
2020-04-30 14:23:55 +02:00
|
|
|
go scpCommand.handle() //nolint:errcheck
|
2019-11-18 23:30:37 +01:00
|
|
|
return true
|
|
|
|
|
}
|
2020-04-30 14:23:55 +02:00
|
|
|
if name != scpCmdName {
|
2020-07-24 23:39:38 +02:00
|
|
|
connection.SetProtocol(common.ProtocolSSH)
|
2019-11-18 23:30:37 +01:00
|
|
|
sshCommand := sshCommand{
|
|
|
|
|
command: name,
|
2020-07-24 23:39:38 +02:00
|
|
|
connection: connection,
|
2023-02-12 18:56:53 +01:00
|
|
|
startTime: time.Now(),
|
2019-11-18 23:30:37 +01:00
|
|
|
args: args,
|
|
|
|
|
}
|
2020-04-30 14:23:55 +02:00
|
|
|
go sshCommand.handle() //nolint:errcheck
|
2019-11-18 23:30:37 +01:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2023-02-27 19:02:43 +01:00
|
|
|
connection.Log(logger.LevelInfo, "ssh command not enabled/supported: %q", name)
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
|
|
|
|
}
|
2021-03-21 19:15:47 +01:00
|
|
|
err := connection.CloseFS()
|
2021-12-16 19:53:00 +01:00
|
|
|
connection.Log(logger.LevelError, "unable to unmarshal ssh command, close fs, err: %v", err)
|
2019-11-18 23:30:37 +01:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2020-10-31 11:02:04 +01:00
|
|
|
func (c *sshCommand) handle() (err error) {
|
|
|
|
|
defer func() {
|
|
|
|
|
if r := recover(); r != nil {
|
2023-02-27 19:02:43 +01:00
|
|
|
logger.Error(logSender, "", "panic in handle ssh command: %q stack trace: %v", r, string(debug.Stack()))
|
2020-10-31 11:02:04 +01:00
|
|
|
err = common.ErrGenericFailure
|
|
|
|
|
}
|
|
|
|
|
}()
|
2022-04-14 19:07:41 +02:00
|
|
|
if err := common.Connections.Add(c.connection); err != nil {
|
2025-09-28 18:15:15 +02:00
|
|
|
defer c.connection.CloseFS() //nolint:errcheck
|
2022-04-14 19:07:41 +02:00
|
|
|
logger.Info(logSender, "", "unable to add SSH command connection: %v", err)
|
2025-09-28 18:15:15 +02:00
|
|
|
return c.sendErrorResponse(err)
|
2022-04-14 19:07:41 +02:00
|
|
|
}
|
2020-07-29 21:56:56 +02:00
|
|
|
defer common.Connections.Remove(c.connection.GetID())
|
2020-07-24 23:39:38 +02:00
|
|
|
|
|
|
|
|
c.connection.UpdateLastActivity()
|
2024-07-24 18:27:13 +02:00
|
|
|
if slices.Contains(sshHashCommands, c.command) {
|
2019-11-26 22:26:42 +01:00
|
|
|
return c.handleHashCommands()
|
2019-11-18 23:30:37 +01:00
|
|
|
} else if c.command == "cd" {
|
|
|
|
|
c.sendExitStatus(nil)
|
|
|
|
|
} else if c.command == "pwd" {
|
2022-12-27 18:51:53 +01:00
|
|
|
// hard coded response to the start directory
|
2022-04-14 19:07:41 +02:00
|
|
|
c.connection.channel.Write([]byte(util.CleanPath(c.connection.User.Filters.StartDirectory) + "\n")) //nolint:errcheck
|
2019-11-18 23:30:37 +01:00
|
|
|
c.sendExitStatus(nil)
|
2020-06-13 22:48:51 +02:00
|
|
|
} else if c.command == "sftpgo-copy" {
|
2021-03-21 19:15:47 +01:00
|
|
|
return c.handleSFTPGoCopy()
|
2020-06-13 22:48:51 +02:00
|
|
|
} else if c.command == "sftpgo-remove" {
|
2021-03-21 19:15:47 +01:00
|
|
|
return c.handleSFTPGoRemove()
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
2020-10-31 11:02:04 +01:00
|
|
|
return
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
|
|
|
|
|
2021-03-21 19:15:47 +01:00
|
|
|
func (c *sshCommand) handleSFTPGoCopy() error {
|
2022-12-27 18:51:53 +01:00
|
|
|
sshSourcePath := c.getSourcePath()
|
|
|
|
|
sshDestPath := c.getDestPath()
|
|
|
|
|
if sshSourcePath == "" || sshDestPath == "" || len(c.args) != 2 {
|
|
|
|
|
return c.sendErrorResponse(errors.New("usage sftpgo-copy <source dir path> <destination dir path>"))
|
2020-06-13 22:48:51 +02:00
|
|
|
}
|
2022-12-27 18:51:53 +01:00
|
|
|
c.connection.Log(logger.LevelDebug, "requested copy %q -> %q", sshSourcePath, sshDestPath)
|
|
|
|
|
if err := c.connection.Copy(sshSourcePath, sshDestPath); err != nil {
|
2020-06-16 22:49:18 +02:00
|
|
|
return c.sendErrorResponse(err)
|
|
|
|
|
}
|
2020-06-13 22:48:51 +02:00
|
|
|
c.connection.channel.Write([]byte("OK\n")) //nolint:errcheck
|
|
|
|
|
c.sendExitStatus(nil)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2021-03-21 19:15:47 +01:00
|
|
|
func (c *sshCommand) handleSFTPGoRemove() error {
|
2020-06-13 22:48:51 +02:00
|
|
|
sshDestPath, err := c.getRemovePath()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.sendErrorResponse(err)
|
|
|
|
|
}
|
2022-12-27 18:51:53 +01:00
|
|
|
if err := c.connection.RemoveAll(sshDestPath); err != nil {
|
2020-06-13 22:48:51 +02:00
|
|
|
return c.sendErrorResponse(err)
|
|
|
|
|
}
|
|
|
|
|
c.connection.channel.Write([]byte("OK\n")) //nolint:errcheck
|
|
|
|
|
c.sendExitStatus(nil)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-26 22:26:42 +01:00
|
|
|
func (c *sshCommand) handleHashCommands() error {
|
|
|
|
|
var h hash.Hash
|
2025-03-29 11:21:01 +01:00
|
|
|
switch c.command {
|
|
|
|
|
case "md5sum":
|
2019-11-26 22:26:42 +01:00
|
|
|
h = md5.New()
|
2025-03-29 11:21:01 +01:00
|
|
|
case "sha1sum":
|
2019-11-26 22:26:42 +01:00
|
|
|
h = sha1.New()
|
2025-03-29 11:21:01 +01:00
|
|
|
case "sha256sum":
|
2019-11-26 22:26:42 +01:00
|
|
|
h = sha256.New()
|
2025-03-29 11:21:01 +01:00
|
|
|
case "sha384sum":
|
2019-11-26 22:26:42 +01:00
|
|
|
h = sha512.New384()
|
2025-03-29 11:21:01 +01:00
|
|
|
default:
|
2019-11-26 22:26:42 +01:00
|
|
|
h = sha512.New()
|
|
|
|
|
}
|
|
|
|
|
var response string
|
|
|
|
|
if len(c.args) == 0 {
|
|
|
|
|
// without args we need to read the string to hash from stdin
|
|
|
|
|
buf := make([]byte, 4096)
|
|
|
|
|
n, err := c.connection.channel.Read(buf)
|
|
|
|
|
if err != nil && err != io.EOF {
|
|
|
|
|
return c.sendErrorResponse(err)
|
|
|
|
|
}
|
2020-04-30 14:23:55 +02:00
|
|
|
h.Write(buf[:n]) //nolint:errcheck
|
2019-11-26 22:26:42 +01:00
|
|
|
response = fmt.Sprintf("%x -\n", h.Sum(nil))
|
|
|
|
|
} else {
|
|
|
|
|
sshPath := c.getDestPath()
|
2022-01-15 17:16:49 +01:00
|
|
|
if ok, policy := c.connection.User.IsFileAllowed(sshPath); !ok {
|
2023-02-27 19:02:43 +01:00
|
|
|
c.connection.Log(logger.LevelInfo, "hash not allowed for file %q", sshPath)
|
2022-01-15 17:16:49 +01:00
|
|
|
return c.sendErrorResponse(c.connection.GetErrorForDeniedFile(policy))
|
2020-03-01 22:10:29 +01:00
|
|
|
}
|
2021-03-21 19:15:47 +01:00
|
|
|
fs, fsPath, err := c.connection.GetFsAndResolvedPath(sshPath)
|
2019-11-26 22:26:42 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return c.sendErrorResponse(err)
|
|
|
|
|
}
|
2020-01-05 11:41:25 +01:00
|
|
|
if !c.connection.User.HasPerm(dataprovider.PermListItems, sshPath) {
|
2021-03-21 19:15:47 +01:00
|
|
|
return c.sendErrorResponse(c.connection.GetPermissionDeniedError())
|
2019-12-25 18:20:19 +01:00
|
|
|
}
|
2021-03-21 19:15:47 +01:00
|
|
|
hash, err := c.computeHashForFile(fs, h, fsPath)
|
2019-11-26 22:26:42 +01:00
|
|
|
if err != nil {
|
2021-03-21 19:15:47 +01:00
|
|
|
return c.sendErrorResponse(c.connection.GetFsError(fs, err))
|
2019-11-26 22:26:42 +01:00
|
|
|
}
|
|
|
|
|
response = fmt.Sprintf("%v %v\n", hash, sshPath)
|
|
|
|
|
}
|
2020-04-30 14:23:55 +02:00
|
|
|
c.connection.channel.Write([]byte(response)) //nolint:errcheck
|
2019-11-26 22:26:42 +01:00
|
|
|
c.sendExitStatus(nil)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-13 22:48:51 +02:00
|
|
|
// for the supported commands, the destination path, if any, is the last argument
|
2019-11-18 23:30:37 +01:00
|
|
|
func (c *sshCommand) getDestPath() string {
|
|
|
|
|
if len(c.args) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2022-03-03 12:44:56 +01:00
|
|
|
return c.cleanCommandPath(c.args[len(c.args)-1])
|
2020-06-13 22:48:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// for the supported commands, the destination path, if any, is the second-last argument
|
|
|
|
|
func (c *sshCommand) getSourcePath() string {
|
|
|
|
|
if len(c.args) < 2 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
2022-03-03 12:44:56 +01:00
|
|
|
return c.cleanCommandPath(c.args[len(c.args)-2])
|
2020-06-13 22:48:51 +02:00
|
|
|
}
|
|
|
|
|
|
2022-03-03 12:44:56 +01:00
|
|
|
func (c *sshCommand) cleanCommandPath(name string) string {
|
2020-06-13 22:48:51 +02:00
|
|
|
name = strings.Trim(name, "'")
|
|
|
|
|
name = strings.Trim(name, "\"")
|
2022-03-03 12:44:56 +01:00
|
|
|
result := c.connection.User.GetCleanedPath(name)
|
2020-06-13 22:48:51 +02:00
|
|
|
if strings.HasSuffix(name, "/") && !strings.HasSuffix(result, "/") {
|
2019-11-18 23:30:37 +01:00
|
|
|
result += "/"
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2020-06-13 22:48:51 +02:00
|
|
|
func (c *sshCommand) getRemovePath() (string, error) {
|
|
|
|
|
sshDestPath := c.getDestPath()
|
2021-01-05 09:50:22 +01:00
|
|
|
if sshDestPath == "" || len(c.args) != 1 {
|
2020-06-13 22:48:51 +02:00
|
|
|
err := errors.New("usage sftpgo-remove <destination path>")
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if len(sshDestPath) > 1 {
|
|
|
|
|
sshDestPath = strings.TrimSuffix(sshDestPath, "/")
|
|
|
|
|
}
|
|
|
|
|
return sshDestPath, nil
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-18 23:30:37 +01:00
|
|
|
func (c *sshCommand) sendErrorResponse(err error) error {
|
2021-03-21 19:15:47 +01:00
|
|
|
errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), err)
|
2020-04-30 14:23:55 +02:00
|
|
|
c.connection.channel.Write([]byte(errorString)) //nolint:errcheck
|
2019-11-18 23:30:37 +01:00
|
|
|
c.sendExitStatus(err)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *sshCommand) sendExitStatus(err error) {
|
|
|
|
|
status := uint32(0)
|
2021-10-10 13:08:05 +02:00
|
|
|
vCmdPath := c.getDestPath()
|
|
|
|
|
cmdPath := ""
|
2020-06-13 22:48:51 +02:00
|
|
|
targetPath := ""
|
2021-10-10 13:08:05 +02:00
|
|
|
vTargetPath := ""
|
2020-06-13 22:48:51 +02:00
|
|
|
if c.command == "sftpgo-copy" {
|
2021-10-10 13:08:05 +02:00
|
|
|
vTargetPath = vCmdPath
|
|
|
|
|
vCmdPath = c.getSourcePath()
|
2020-06-13 22:48:51 +02:00
|
|
|
}
|
2019-11-18 23:30:37 +01:00
|
|
|
if err != nil {
|
|
|
|
|
status = uint32(1)
|
2022-12-27 18:51:53 +01:00
|
|
|
c.connection.Log(logger.LevelError, "command failed: %q args: %v user: %s err: %v",
|
2019-11-19 11:38:39 +01:00
|
|
|
c.command, c.args, c.connection.User.Username, err)
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
|
|
|
|
exitStatus := sshSubsystemExitStatus{
|
|
|
|
|
Status: status,
|
|
|
|
|
}
|
2021-05-26 07:48:37 +02:00
|
|
|
_, errClose := c.connection.channel.(ssh.Channel).SendRequest("exit-status", false, ssh.Marshal(&exitStatus))
|
|
|
|
|
c.connection.Log(logger.LevelDebug, "exit status sent, error: %v", errClose)
|
2019-11-18 23:30:37 +01:00
|
|
|
c.connection.channel.Close()
|
2019-11-26 22:26:42 +01:00
|
|
|
// for scp we notify single uploads/downloads
|
2020-04-30 14:23:55 +02:00
|
|
|
if c.command != scpCmdName {
|
2023-02-12 18:56:53 +01:00
|
|
|
elapsed := time.Since(c.startTime).Nanoseconds() / 1000000
|
2021-07-11 15:26:51 +02:00
|
|
|
metric.SSHCommandCompleted(err)
|
2021-10-10 13:08:05 +02:00
|
|
|
if vCmdPath != "" {
|
|
|
|
|
_, p, errFs := c.connection.GetFsAndResolvedPath(vCmdPath)
|
2021-03-21 19:15:47 +01:00
|
|
|
if errFs == nil {
|
2020-06-13 22:48:51 +02:00
|
|
|
cmdPath = p
|
|
|
|
|
}
|
|
|
|
|
}
|
2021-10-10 13:08:05 +02:00
|
|
|
if vTargetPath != "" {
|
|
|
|
|
_, p, errFs := c.connection.GetFsAndResolvedPath(vTargetPath)
|
2021-03-21 19:15:47 +01:00
|
|
|
if errFs == nil {
|
2020-06-13 22:48:51 +02:00
|
|
|
targetPath = p
|
2020-01-09 12:00:37 +01:00
|
|
|
}
|
|
|
|
|
}
|
2022-06-16 18:42:17 +02:00
|
|
|
common.ExecuteActionNotification(c.connection.BaseConnection, common.OperationSSHCmd, cmdPath, vCmdPath, //nolint:errcheck
|
2023-08-12 18:51:47 +02:00
|
|
|
targetPath, vTargetPath, c.command, 0, err, elapsed, nil)
|
2021-06-01 22:28:43 +02:00
|
|
|
if err == nil {
|
|
|
|
|
logger.CommandLog(sshCommandLogSender, cmdPath, targetPath, c.connection.User.Username, "", c.connection.ID,
|
2021-07-24 20:11:17 +02:00
|
|
|
common.ProtocolSSH, -1, -1, "", "", c.connection.command, -1, c.connection.GetLocalAddress(),
|
2023-02-12 18:56:53 +01:00
|
|
|
c.connection.GetRemoteAddress(), elapsed)
|
2021-06-01 22:28:43 +02:00
|
|
|
}
|
2019-11-26 22:26:42 +01:00
|
|
|
}
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|
|
|
|
|
|
2021-03-21 19:15:47 +01:00
|
|
|
func (c *sshCommand) computeHashForFile(fs vfs.Fs, hasher hash.Hash, path string) (string, error) {
|
2019-11-18 23:30:37 +01:00
|
|
|
hash := ""
|
2021-03-21 19:15:47 +01:00
|
|
|
f, r, _, err := fs.Open(path, 0)
|
2019-11-18 23:30:37 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return hash, err
|
|
|
|
|
}
|
2020-12-13 15:11:55 +01:00
|
|
|
var reader io.ReadCloser
|
|
|
|
|
if f != nil {
|
|
|
|
|
reader = f
|
|
|
|
|
} else {
|
|
|
|
|
reader = r
|
|
|
|
|
}
|
|
|
|
|
defer reader.Close()
|
|
|
|
|
_, err = io.Copy(hasher, reader)
|
2019-11-18 23:30:37 +01:00
|
|
|
if err == nil {
|
|
|
|
|
hash = fmt.Sprintf("%x", hasher.Sum(nil))
|
|
|
|
|
}
|
|
|
|
|
return hash, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseCommandPayload(command string) (string, []string, error) {
|
2020-02-14 16:17:32 +01:00
|
|
|
parts, err := shlex.Split(command)
|
|
|
|
|
if err == nil && len(parts) == 0 {
|
2023-02-27 19:02:43 +01:00
|
|
|
err = fmt.Errorf("invalid command: %q", command)
|
2020-02-14 16:17:32 +01:00
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", []string{}, err
|
|
|
|
|
}
|
2019-11-18 23:30:37 +01:00
|
|
|
if len(parts) < 2 {
|
|
|
|
|
return parts[0], []string{}, nil
|
|
|
|
|
}
|
2020-02-14 16:17:32 +01:00
|
|
|
return parts[0], parts[1:], nil
|
2019-11-18 23:30:37 +01:00
|
|
|
}
|