Added deployment-password flag, generate salts if invalid, added section for automatic deployment to docs

This commit is contained in:
Marc Ole Bulling
2024-05-22 23:12:45 +02:00
parent 486c1d51a5
commit 5a31cd5d63
7 changed files with 242 additions and 80 deletions
+24 -14
View File
@@ -51,6 +51,7 @@ func main() {
fmt.Println("Gokapi v" + versionGokapi + " starting")
setup.RunIfFirstStart()
configuration.Load()
setDeploymentPassword(passedFlags)
reconfigureServer(passedFlags)
encryption.Init(*configuration.Get())
authentication.Init(configuration.Get().Authentication)
@@ -75,21 +76,22 @@ func shutdown() {
// Checks for command line arguments that have to be parsed before loading the configuration
func showVersion(passedFlags flagparser.MainFlags) {
if passedFlags.ShowVersion {
fmt.Println("Gokapi v" + versionGokapi)
fmt.Println()
fmt.Println("Builder: " + environment.Builder)
fmt.Println("Build Date: " + environment.BuildTime)
fmt.Println("Is Docker Version: " + environment.IsDocker)
info, ok := debug.ReadBuildInfo()
if ok {
fmt.Println("Go Version: " + info.GoVersion)
} else {
fmt.Println("Go Version: unknown")
}
parseBuildSettings(info.Settings)
osExit(0)
if !passedFlags.ShowVersion {
return
}
fmt.Println("Gokapi v" + versionGokapi)
fmt.Println()
fmt.Println("Builder: " + environment.Builder)
fmt.Println("Build Date: " + environment.BuildTime)
fmt.Println("Is Docker Version: " + environment.IsDocker)
info, ok := debug.ReadBuildInfo()
if ok {
fmt.Println("Go Version: " + info.GoVersion)
} else {
fmt.Println("Go Version: unknown")
}
parseBuildSettings(info.Settings)
osExit(0)
}
func parseBuildSettings(infos []debug.BuildSetting) {
@@ -173,6 +175,14 @@ func handleServiceInstall(passedFlags flagparser.MainFlags) {
}
}
func setDeploymentPassword(passedFlags flagparser.MainFlags) {
if passedFlags.DeploymentPassword == "" {
return
}
logging.AddString("Password has been changed for deployment")
configuration.SetDeploymentPassword(passedFlags.DeploymentPassword)
}
var osExit = os.Exit
// ASCII art logo
+73 -18
View File
@@ -10,22 +10,22 @@ Advanced usage
Environment variables
********************************
Environment variables can be passed to Gokapi - that way you can set it up without any interaction and pass cloud storage credentials without saving them to the filesystem.
Several environment variables can be passed to Gokapi. They can be used to modify settings that are not present during setup or to pass cloud storage credentials without saving them to the filesystem.
.. _passingenv:
Passing environment variables to Gokapi
===============================================
=========================================
Docker
------
Pass the variable with the ``-e`` argument. Example for setting the username to *admin* and the password to *123456*:
Pass the variable with the ``-e`` argument. Example for setting the port in use to *12345* and the database filename to *database.sqlite*:
::
docker run -it -e GOKAPI_USERNAME=admin -e GOKAPI_PASSWORD=123456 f0rc3/gokapi:latest
docker run -it -e GOKAPI_PORT=12345 -e GOKAPI_DB_NAME=database.sqlite f0rc3/gokapi:latest
Bare Metal
@@ -90,21 +90,21 @@ Available environment variables
All values that are described in :ref:`cloudstorage` can be passed as environment variables as well. No values are persistent, therefore need to be set on every start.
All values that are described in :ref:`cloudstorage` can be passed as environment variables as well. No values are persistent; therefore, they need to be set on every start.
+-----------------------+-------------------------+
| Name | Action |
+=======================+=========================+
| GOKAPI_AWS_BUCKET | Sets the bucket name |
+-----------------------+-------------------------+
| GOKAPI_AWS_REGION | Sets the region name |
+-----------------------+-------------------------+
| GOKAPI_AWS_KEY | Sets the API key |
+-----------------------+-------------------------+
| GOKAPI_AWS_KEY_SECRET | Sets the API key secret |
+-----------------------+-------------------------+
| GOKAPI_AWS_ENDPOINT | Sets the endpoint |
+-----------------------+-------------------------+
+-----------------------+-------------------------+-----------------------------+
| Name | Action | Example |
+=======================+=========================+=============================+
| GOKAPI_AWS_BUCKET | Sets the bucket name | gokapi |
+-----------------------+-------------------------+-----------------------------+
| GOKAPI_AWS_REGION | Sets the region name | eu-central-000 |
+-----------------------+-------------------------+-----------------------------+
| GOKAPI_AWS_KEY | Sets the API key | 123456789 |
+-----------------------+-------------------------+-----------------------------+
| GOKAPI_AWS_KEY_SECRET | Sets the API key secret | abcdefg123 |
+-----------------------+-------------------------+-----------------------------+
| GOKAPI_AWS_ENDPOINT | Sets the endpoint | eu-central-000.provider.com |
+-----------------------+-------------------------+-----------------------------+
.. _api:
@@ -142,6 +142,61 @@ Example: Deleting a file
********************************
Automatic Deployment
********************************
It is possible to deploy Gokapi without having to run the setup. You will need to complete the setup on a temporary instance first. This is to create the configuration files, which can then be used for deployment.
Configuration Files
============================
The configuration consists of up to two files in the configuration directory (default: ``config``). All files can be read-only, however ``config.json`` might need write access in some situations.
cloudconfig.yml
------------------------
Stores the access data for cloud storage. This can be reused without modification, however all fields can also be set with environment variables. The file does not exist if no cloud storage is used and can always be read-only.
config.json
------------------------
Contains the server configuration. If you want to deploy Gokapi in multiple instances for redundancy (e.g. all instances share the same data), then the configuration file can be reused without modification. Otherwise you need to modify it before deploying (see below). Can be read-only, but might need write access when upgrading Gokapi to a newer version. Needs write access when re-running setup or changing the admin password.
Modifying config.json to deploy without setup
====================================================
If you want to deploy Gokapi to multiple instances that contain different data, you have to modify the config.json. Open it and change the following fields:
+-----------+------------------------------------------------------------+----------------------+
| Field | Operation | Example |
+===========+============================================================+======================+
| SaltAdmin | Change to empty value | "SaltAdmin": "", |
+-----------+------------------------------------------------------------+----------------------+
| SaltFiles | Change to empty value | "SaltFiles": "", |
+-----------+------------------------------------------------------------+----------------------+
| Password | Change to empty value | "Password": "", |
+-----------+------------------------------------------------------------+----------------------+
| Username | Change to the username of your preference, | "Username": "admin", |
| | | |
| | if you are using internal username/password authentication | |
+-----------+------------------------------------------------------------+----------------------+
Setting an admin password
====================================================
If you are using internal username/password authentication, run the binary with the parameter ``--deployment-password [YOUR_PASSWORD]``. This sets the password and also generates a new salt for the password. This has to be done before Gokapi is run for the first time on the new instance. Alternatively you can do this on the orchestrating machine and then copy the configuration file to the new instance.
If you are using a Docker image, this has to be done by starting a container with the entrypoint ``/app/run.sh``, for example: ::
docker run --rm -v gokapi-data:/app/data -v gokapi-config:/app/config f0rc3/gokapi:latest /app/run.sh --deployment-password newPassword
********************************
Customising
********************************
+59 -12
View File
@@ -24,8 +24,8 @@ import (
"strings"
)
// Min length of admin password in characters
const minLengthPassword = 6
// MinLengthPassword is the required length of admin password in characters
const MinLengthPassword = 8
// Environment is an object containing the environment variables
var Environment environment.Environment
@@ -41,17 +41,42 @@ func Exists() bool {
return helper.FileExists(configPath)
}
// loadFromFile parses the given file and adds salts, if they are invalid
func loadFromFile(path string) (models.Configuration, error) {
file, err := os.Open(path)
if err != nil {
return models.Configuration{}, err
}
decoder := json.NewDecoder(file)
settings := models.Configuration{}
err = decoder.Decode(&settings)
if err != nil {
return models.Configuration{}, err
}
err = file.Close()
if err != nil {
return models.Configuration{}, err
}
if len(settings.Authentication.SaltFiles) < 20 {
settings.Authentication.SaltFiles = helper.GenerateRandomString(30)
fmt.Println("Warning: Salt for file hash invalid, generating new salt")
}
if len(settings.Authentication.SaltAdmin) < 20 {
settings.Authentication.SaltAdmin = helper.GenerateRandomString(30)
if settings.Authentication.Method == 0 { // == authentication.Internal, but would create import cycle
fmt.Println("Warning: Salt for admin password invalid, generating new salt. You will need to reset the admin password.")
}
}
return settings, nil
}
// Load loads the configuration or creates the folder structure and a default configuration
func Load() {
Environment = environment.New()
// No check if file exists, as this was checked earlier
file, err := os.Open(Environment.ConfigPath)
settings, err := loadFromFile(Environment.ConfigPath)
helper.Check(err)
decoder := json.NewDecoder(file)
serverSettings = models.Configuration{}
err = decoder.Decode(&serverSettings)
helper.Check(err)
file.Close()
serverSettings = settings
database.Init(serverSettings.DataDir, Environment.DatabaseName)
if configupgrade.DoUpgrade(&serverSettings, &Environment) {
save()
@@ -83,7 +108,7 @@ func Get() *models.Configuration {
func save() {
file, err := os.OpenFile(Environment.ConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
fmt.Println("Error reading configuration:", err)
fmt.Println("Error writing configuration:", err)
os.Exit(1)
}
defer file.Close()
@@ -123,11 +148,33 @@ func LoadFromSetup(config models.Configuration, cloudConfig *cloudconfig.CloudCo
Load()
}
// SetDeploymentPassword sets a new password. This should only be used for non-interactive deployment, but is not enforced
func SetDeploymentPassword(newPassword string) {
if len(newPassword) < MinLengthPassword {
fmt.Printf("Password needs to be at least %d characters long\n", MinLengthPassword)
os.Exit(1)
}
serverSettings.Authentication.SaltAdmin = helper.GenerateRandomString(30)
serverSettings.Authentication.Password = hashUserPassword(newPassword)
database.DeleteAllSessions()
save()
fmt.Println("New password has been set successfully")
os.Exit(0)
}
// HashPassword hashes a string with SHA1 the file salt or admin user salt
func HashPassword(password string, useFileSalt bool) string {
if useFileSalt {
return HashPasswordCustomSalt(password, serverSettings.Authentication.SaltFiles)
return hashFilePassword(password)
}
return hashUserPassword(password)
}
func hashFilePassword(password string) string {
return HashPasswordCustomSalt(password, serverSettings.Authentication.SaltFiles)
}
func hashUserPassword(password string) string {
return HashPasswordCustomSalt(password, serverSettings.Authentication.SaltAdmin)
}
@@ -139,8 +186,8 @@ func HashPasswordCustomSalt(password, salt string) string {
if salt == "" {
panic(errors.New("no salt provided"))
}
bytes := []byte(password + salt)
pwBytes := []byte(password + salt)
hash := sha1.New()
hash.Write(bytes)
hash.Write(pwBytes)
return hex.EncodeToString(hash.Sum(nil))
}
+13 -10
View File
@@ -131,7 +131,7 @@ func startSetupWebserver() {
log.Fatalf("Setup Webserver: %v", err)
}
err = srv.Serve(listener)
if err != nil && err != http.ErrServerClosed {
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Setup Webserver: %v", err)
}
}
@@ -145,10 +145,11 @@ func isErrorAddressAlreadyInUse(err error) bool {
if !errors.As(eOsSyscall, &errErrno) {
return false
}
if errErrno == syscall.EADDRINUSE {
if errors.Is(errErrno, syscall.EADDRINUSE) {
return true
}
const WSAEADDRINUSE = 10048
//noinspection GoBoolExpressions
if runtime.GOOS == "windows" && errErrno == WSAEADDRINUSE {
return true
}
@@ -530,9 +531,8 @@ func parseEncryptionAndDelete(result *models.Configuration, formObjects *[]jsonF
if encLevel == encryption.LocalEncryptionInput || encLevel == encryption.FullEncryptionInput {
result.Encryption.Salt = helper.GenerateRandomString(30)
result.Encryption.ChecksumSalt = helper.GenerateRandomString(30)
const minLength = 8
if len(masterPw) < minLength {
return errors.New("password is less than " + strconv.Itoa(minLength) + " characters long")
if len(masterPw) < configuration.MinLengthPassword {
return errors.New("password is less than " + strconv.Itoa(configuration.MinLengthPassword) + " characters long")
}
result.Encryption.Checksum = encryption.PasswordChecksum(masterPw, result.Encryption.ChecksumSalt)
}
@@ -635,7 +635,7 @@ func handleShowSetup(w http.ResponseWriter, r *http.Request) {
}
func handleShowMaintenance(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Server is in maintenance mode, please try again in a few minutes."))
_, _ = w.Write([]byte("Server is in maintenance mode, please try again in a few minutes."))
}
// Handling of /setupResult
@@ -653,16 +653,19 @@ func handleResult(w http.ResponseWriter, r *http.Request) {
}
configuration.LoadFromSetup(newConfig, cloudSettings, isInitialSetup)
w.WriteHeader(200)
w.Write([]byte("{ \"result\": \"OK\"}"))
_, _ = w.Write([]byte("{ \"result\": \"OK\"}"))
go func() {
time.Sleep(1500 * time.Millisecond)
srv.Shutdown(context.Background())
err = srv.Shutdown(context.Background())
if err != nil {
fmt.Println(err)
}
}()
}
func outputError(w http.ResponseWriter, err error) {
w.WriteHeader(500)
w.Write([]byte("{ \"result\": \"Error\", \"error\": \"" + err.Error() + "\"}"))
_, _ = w.Write([]byte("{ \"result\": \"Error\", \"error\": \"" + err.Error() + "\"}"))
}
// Adds a / character to the end of a URL if it does not exist
@@ -696,7 +699,7 @@ func handleTestAws(w http.ResponseWriter, r *http.Request) {
var t testAwsRequest
err := decoder.Decode(&t)
if err != nil {
w.Write([]byte("Error: " + err.Error()))
_, _ = w.Write([]byte("Error: " + err.Error()))
return
}
var awsConfig models.AwsConfig
+27 -24
View File
@@ -55,6 +55,7 @@ func ParseFlags() MainFlags {
installService := passedFlags.Bool("install-service", false, "Installs Gokapi as a systemd service")
uninstallService := passedFlags.Bool("uninstall-service", false, "Uninstalls the Gokapi systemd service")
deploymentPassword := passedFlags.String("deployment-password", "", "Sets a new password. This should only be used for non-interactive deployment")
passedFlags.Usage = showUsage(passedFlags, aliases)
err := passedFlags.Parse(os.Args[1:])
@@ -67,16 +68,17 @@ func ParseFlags() MainFlags {
}
result := MainFlags{
ShowVersion: *versionFlagShort || *versionFlagLong,
Reconfigure: *reconfigureFlag,
CreateSsl: *createSslFlag,
ConfigPath: getAliasedString(configPathFlagLong, configPathFlagShort),
ConfigDir: getAliasedString(configDirFlagLong, configDirFlagShort),
DataDir: getAliasedString(dataDirFlagLong, dataDirFlagShort),
Port: getAliasedInt(portFlagLong, portFlagShort),
DisableCorsCheck: *disableCorsCheck,
InstallService: *installService,
UninstallService: *uninstallService,
ShowVersion: *versionFlagShort || *versionFlagLong,
Reconfigure: *reconfigureFlag,
CreateSsl: *createSslFlag,
ConfigPath: getAliasedString(configPathFlagLong, configPathFlagShort),
ConfigDir: getAliasedString(configDirFlagLong, configDirFlagShort),
DataDir: getAliasedString(dataDirFlagLong, dataDirFlagShort),
Port: getAliasedInt(portFlagLong, portFlagShort),
DisableCorsCheck: *disableCorsCheck,
InstallService: *installService,
UninstallService: *uninstallService,
DeploymentPassword: *deploymentPassword,
}
result.setBoolValues()
return result
@@ -120,20 +122,21 @@ func getAliasedInt(flag1, flag2 *int) int {
// MainFlags holds info for the parsed program arguments
type MainFlags struct {
ShowVersion bool
Reconfigure bool
CreateSsl bool
ConfigPath string
ConfigDir string
DataDir string
Port int
IsConfigPathSet bool
IsConfigDirSet bool
IsDataDirSet bool
IsPortSet bool
DisableCorsCheck bool
InstallService bool
UninstallService bool
ShowVersion bool
Reconfigure bool
CreateSsl bool
ConfigPath string
ConfigDir string
DataDir string
DeploymentPassword string
Port int
IsConfigPathSet bool
IsConfigDirSet bool
IsDataDirSet bool
IsPortSet bool
DisableCorsCheck bool
InstallService bool
UninstallService bool
}
func (mf *MainFlags) setBoolValues() {
+5 -1
View File
@@ -97,7 +97,11 @@ func TestLogin(t *testing.T) {
}
test.HttpPostRequest(t, config)
configuration.Get().Authentication.Method = authentication.OAuth2
oauthConfig := configuration.Get()
oauthConfig.Authentication.Method = authentication.OAuth2
oauthConfig.Authentication.OAuthProvider = "http://test.com"
oauthConfig.Authentication.OAuthClientSecret = "secret"
oauthConfig.Authentication.OAuthClientId = "client"
authentication.Init(configuration.Get().Authentication)
config.RequiredContent = []string{"\"Refresh\" content=\"0; URL=./oauth-login\""}
config.PostValues = []test.PostBody{}
@@ -3,12 +3,14 @@ package authentication
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/webserver/authentication/sessionmanager"
"io"
"log"
"net/http"
"regexp"
"strings"
@@ -33,9 +35,48 @@ var authSettings models.AuthenticationConfig
// Init needs to be called first to process the authentication configuration
func Init(config models.AuthenticationConfig) {
valid, err := isValid(config)
if !valid {
log.Println("Error while initiating authentication method:")
log.Fatal(err)
}
authSettings = config
}
// isValid checks if the config is actually valid, and returns true or returns false and an error
func isValid(config models.AuthenticationConfig) (bool, error) {
switch config.Method {
case Internal:
if len(config.Username) < 3 {
return false, errors.New("username too short")
}
if len(config.Password) != 40 {
return false, errors.New("password does not appear to be a SHA-1 hash")
}
return true, nil
case OAuth2:
if config.OAuthProvider == "" {
return false, errors.New("oauth provider was not set")
}
if config.OAuthClientId == "" {
return false, errors.New("oauth client id was not set")
}
if config.OAuthClientSecret == "" {
return false, errors.New("oauth client secret was not set")
}
return true, nil
case Header:
if config.HeaderKey == "" {
return false, errors.New("header key is not set")
}
return true, nil
case Disabled:
return true, nil
default:
return false, errors.New("unknown authentication selected")
}
}
// IsAuthenticated returns true if the user provides a valid authentication
func IsAuthenticated(w http.ResponseWriter, r *http.Request) bool {
switch authSettings.Method {
@@ -53,7 +94,6 @@ func IsAuthenticated(w http.ResponseWriter, r *http.Request) bool {
// isGrantedHeader returns true if the user was authenticated by a proxy header if enabled
func isGrantedHeader(r *http.Request) bool {
if authSettings.HeaderKey == "" {
return false
}