diff --git a/Dockerfile b/Dockerfile index 474e30be..12935d57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,6 +95,7 @@ RUN apk add tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && apk del tzdata COPY conf/app.yml /etc/kubepi/app.yml +RUN chmod 600 /etc/kubepi/app.yml COPY vimrc.local /etc/vim diff --git a/cmd/server/script/darwin/init-kube.sh b/cmd/server/script/darwin/init-kube.sh index 363fe02f..89ac2860 100644 --- a/cmd/server/script/darwin/init-kube.sh +++ b/cmd/server/script/darwin/init-kube.sh @@ -6,35 +6,33 @@ start_shell() { exec /bin/bash } -fail_and_shell() { - echo "$1" - start_shell +fail_and_exit() { + echo "$1" >&2 + exit 1 } arg1=${1:-} if [[ -z "${arg1}" ]]; then - fail_and_shell "missing terminal session token" + fail_and_exit "missing terminal session token" fi -terminal_home=$(mktemp -d "${TMPDIR:-/tmp}/kubepi-terminal.XXXXXX") || fail_and_shell "can not create terminal home" +terminal_home=$(mktemp -d "${TMPDIR:-/tmp}/kubepi-terminal.XXXXXX") || fail_and_exit "can not create terminal home" export HOME="${terminal_home}" -mkdir -p ~/.kube || fail_and_shell "can not create kubeconfig directory" +mkdir -p ~/.kube || fail_and_exit "can not create kubeconfig directory" session_url=${KUBEPI_WEBKUBECTL_SESSION_URL:-http://127.0.0.1/kubepi/api/v1/webkubectl/session} code=$(curl --connect-timeout 5 --max-time 20 -w "%{http_code}" -s -o ~/.kube/config "${session_url}?token=${arg1}") curl_status=$? if [[ $curl_status -ne 0 || "${code}" != "200" ]];then - echo "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" rm -f ~/.kube/config - start_shell + fail_and_exit "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" fi current_context=$(kubectl config current-context 2>/tmp/kubepi-kubectl.err) if [[ $? -ne 0 || -z "${current_context}" ]]; then - echo "load kubeconfig failed" cat /tmp/kubepi-kubectl.err - start_shell + fail_and_exit "load kubeconfig failed" fi cluster=${current_context%@*} username=${current_context#*@} diff --git a/cmd/server/script/linux/init-kube.sh b/cmd/server/script/linux/init-kube.sh index f91b3871..74cfe6c6 100644 --- a/cmd/server/script/linux/init-kube.sh +++ b/cmd/server/script/linux/init-kube.sh @@ -3,22 +3,26 @@ set -u start_shell() { export TMPDIR=/nonexistent - if command -v su >/dev/null 2>&1 && id nobody >/dev/null 2>&1; then - su -s /bin/bash nobody - status=$? - if [[ $status -eq 0 ]]; then - exit 0 - fi - echo "warning: can not start shell as nobody, fallback to bash" + if ! command -v su >/dev/null 2>&1 || ! id nobody >/dev/null 2>&1; then + echo "can not start terminal as nobody" >&2 + exit 1 fi - exec /bin/bash + exec su -s /bin/bash nobody + echo "can not start terminal as nobody" >&2 + exit 1 } -fail_and_shell() { - echo "$1" - start_shell +fail_and_exit() { + echo "$1" >&2 + exit 1 } +arg1=${1:-} + +if [[ -z "${arg1}" ]]; then + fail_and_exit "missing terminal session token" +fi + echo "export TERM=xterm-256color" >> /root/.bashrc 2>/dev/null || true echo "source /usr/share/bash-completion/bash_completion" >> /root/.bashrc 2>/dev/null || true echo 'source <(kubectl completion bash)' >> /root/.bashrc 2>/dev/null || true @@ -28,22 +32,16 @@ if [ "${WELCOME_BANNER:-}" ]; then echo ${WELCOME_BANNER} fi -arg1=${1:-} - -if [[ -z "${arg1}" ]]; then - fail_and_shell "missing terminal session token" -fi - -mkdir -p /nonexistent || fail_and_shell "can not create terminal home" +mkdir -p /nonexistent || fail_and_exit "can not create terminal home" mount -t tmpfs -o size=10m tmpfs /nonexistent 2>/tmp/kubepi-mount.err || { echo "warning: can not mount tmpfs for terminal home" cat /tmp/kubepi-mount.err } -cd /nonexistent || fail_and_shell "can not enter terminal home" +cd /nonexistent || fail_and_exit "can not enter terminal home" cp /root/.bashrc ./ 2>/dev/null || touch .bashrc cp /etc/vim/vimrc.local .vimrc 2>/dev/null || true echo 'source /opt/kubectl-aliases/.kubectl_aliases' >> .bashrc -mkdir -p .kube || fail_and_shell "can not create kubeconfig directory" +mkdir -p .kube || fail_and_exit "can not create kubeconfig directory" export HOME=/nonexistent @@ -52,16 +50,14 @@ code=$(curl --connect-timeout 5 --max-time 20 -w "%{http_code}" -s -o ~/.kube/co curl_status=$? if [[ $curl_status -ne 0 || "${code}" != "200" ]];then - echo "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" rm -f .kube/config - start_shell + fail_and_exit "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" fi current_context=$(kubectl config current-context 2>/tmp/kubepi-kubectl.err) if [[ $? -ne 0 || -z "${current_context}" ]]; then - echo "load kubeconfig failed" cat /tmp/kubepi-kubectl.err - start_shell + fail_and_exit "load kubeconfig failed" fi cluster=${current_context%@*} username=${current_context#*@} diff --git a/init-kube.sh b/init-kube.sh index 363fe02f..89ac2860 100755 --- a/init-kube.sh +++ b/init-kube.sh @@ -6,35 +6,33 @@ start_shell() { exec /bin/bash } -fail_and_shell() { - echo "$1" - start_shell +fail_and_exit() { + echo "$1" >&2 + exit 1 } arg1=${1:-} if [[ -z "${arg1}" ]]; then - fail_and_shell "missing terminal session token" + fail_and_exit "missing terminal session token" fi -terminal_home=$(mktemp -d "${TMPDIR:-/tmp}/kubepi-terminal.XXXXXX") || fail_and_shell "can not create terminal home" +terminal_home=$(mktemp -d "${TMPDIR:-/tmp}/kubepi-terminal.XXXXXX") || fail_and_exit "can not create terminal home" export HOME="${terminal_home}" -mkdir -p ~/.kube || fail_and_shell "can not create kubeconfig directory" +mkdir -p ~/.kube || fail_and_exit "can not create kubeconfig directory" session_url=${KUBEPI_WEBKUBECTL_SESSION_URL:-http://127.0.0.1/kubepi/api/v1/webkubectl/session} code=$(curl --connect-timeout 5 --max-time 20 -w "%{http_code}" -s -o ~/.kube/config "${session_url}?token=${arg1}") curl_status=$? if [[ $curl_status -ne 0 || "${code}" != "200" ]];then - echo "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" rm -f ~/.kube/config - start_shell + fail_and_exit "download kubeconfig failed (curl: ${curl_status}, http: ${code:-000})" fi current_context=$(kubectl config current-context 2>/tmp/kubepi-kubectl.err) if [[ $? -ne 0 || -z "${current_context}" ]]; then - echo "load kubeconfig failed" cat /tmp/kubepi-kubectl.err - start_shell + fail_and_exit "load kubeconfig failed" fi cluster=${current_context%@*} username=${current_context#*@} diff --git a/internal/api/v1/v1.go b/internal/api/v1/v1.go index cf8fa06a..95033e98 100644 --- a/internal/api/v1/v1.go +++ b/internal/api/v1/v1.go @@ -425,8 +425,7 @@ func resourceNameInvalidHandler() iris.Handler { } func WarpedJwtHandler() iris.Handler { - verifier := jwt.NewVerifier(jwt.HS256, server.Config().Spec.Jwt.Key) - verifier.WithDefaultBlocklist() + verifier := server.JWTVerifier() verifyMiddleware := verifier.Verify(func() interface{} { return new(session.UserProfile) }) diff --git a/internal/config/config.go b/internal/config/config.go index ca609c9b..2052963f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,9 @@ func ReadConfig(c *config.Config, path ...string) error { fmt.Println(fmt.Sprintf(configNotFoundSkipErr, realDir)) continue } + if err := secureConfigFile(configFile); err != nil { + return err + } v.SetConfigFile(configFile) if err := v.MergeInConfig(); err != nil { @@ -79,11 +82,28 @@ func ReadConfig(c *config.Config, path ...string) error { if err := v.WriteConfig(); err != nil { return err } + if err := secureConfigFile(loadedConfigFile); err != nil { + return err + } } } return nil } +func secureConfigFile(configFile string) error { + info, err := os.Stat(configFile) + if err != nil { + return fmt.Errorf("can not inspect config file %s: %w", configFile, err) + } + if info.Mode().Perm()&0077 == 0 { + return nil + } + if err := os.Chmod(configFile, 0600); err != nil { + return fmt.Errorf("can not secure config file %s: %w", configFile, err) + } + return nil +} + func resolveConfigFile(path string) (string, bool, error) { info, err := os.Stat(path) if err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 1080ab65..783bd142 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "embed" + "encoding/json" "fmt" "io/fs" "net/http" @@ -26,6 +27,7 @@ import ( "github.com/asdine/storm/v3" "github.com/kataras/iris/v12" "github.com/kataras/iris/v12/context" + "github.com/kataras/iris/v12/middleware/jwt" "github.com/kataras/iris/v12/sessions" "github.com/kataras/iris/v12/view" "github.com/sirupsen/logrus" @@ -34,6 +36,11 @@ import ( const SessionCookieName = "SESS_COOKIE_KUBEPI" var SessionMgr *sessions.Sessions +var jwtVerifier *jwt.Verifier + +func JWTVerifier() *jwt.Verifier { + return jwtVerifier +} var EmbedWebKubePi embed.FS var EmbedWebDashboard embed.FS @@ -168,6 +175,8 @@ func (e *KubePiServer) setUpStaticFile() { func (e *KubePiServer) setUpSession() { SessionMgr = sessions.New(sessions.Config{Cookie: SessionCookieName, AllowReclaim: true, Expires: time.Duration(e.config.Spec.Session.Expires) * time.Hour}) + jwtVerifier = jwt.NewVerifier(jwt.HS256, e.config.Spec.Jwt.Key) + jwtVerifier.WithDefaultBlocklist() e.rootRoute.Use(SessionMgr.Handler()) } @@ -266,14 +275,72 @@ func (e *KubePiServer) setUpErrHandler() { func (e *KubePiServer) runMigrations() { migrate.RunMigrate(e.db, e.logger) } + +type webkubectlJWTClaims struct { + Name string `json:"name"` + ForceChangePassword bool `json:"forceChangePassword"` + Mfa struct { + Enable bool `json:"enable"` + Approved bool `json:"approved"` + } `json:"mfa"` +} + +func isActiveWebkubectlProfile(profile interface{}) bool { + payload, err := json.Marshal(profile) + if err != nil { + return false + } + return isActiveWebkubectlPayload(payload) +} + +func isActiveWebkubectlPayload(payload []byte) bool { + var claims webkubectlJWTClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return false + } + return claims.Name != "" && (!claims.Mfa.Enable || claims.Mfa.Approved) && !claims.ForceChangePassword +} + +func hasValidWebkubectlJWT(ctx *context.Context) bool { + if jwtVerifier == nil { + return false + } + token := jwt.FromHeader(ctx) + if token == "" { + return false + } + verifiedToken, err := jwtVerifier.VerifyToken([]byte(token), jwtVerifier.Blocklist) + if err != nil { + return false + } + return isActiveWebkubectlPayload(verifiedToken.Payload) +} + +func requireWebkubectlSession(ctx *context.Context) bool { + if SessionMgr != nil { + if profile := SessionMgr.Start(ctx).Get("profile"); profile != nil && isActiveWebkubectlProfile(profile) { + return true + } + } + if hasValidWebkubectlJWT(ctx) { + return true + } + ctx.Values().Set("message", "please login") + ctx.StopWithStatus(iris.StatusUnauthorized) + return false +} + func (e *KubePiServer) setWebkubectlProxy() { handler := func(ctx *context.Context) { + if !requireWebkubectlSession(ctx) { + return + } p := ctx.Params().Get("p") if strings.Contains(p, "root") { ctx.Request().URL.Path = strings.ReplaceAll(ctx.Request().URL.Path, "root", "") ctx.Request().RequestURI = strings.ReplaceAll(ctx.Request().RequestURI, "root", "") } - u, _ := url.Parse("http://localhost:8080") + u, _ := url.Parse(webkubectlBackendURL) proxy := httputil.NewSingleHostReverseProxy(u) proxy.ModifyResponse = func(resp *http.Response) error { if resp.StatusCode == iris.StatusMovedPermanently { diff --git a/internal/server/tty.go b/internal/server/tty.go index c88526f6..740166be 100644 --- a/internal/server/tty.go +++ b/internal/server/tty.go @@ -6,6 +6,11 @@ import ( "strings" ) +const ( + webkubectlBindAddress = "127.0.0.1" + webkubectlBackendURL = "http://127.0.0.1:8080" +) + func (e *KubePiServer) localWebkubectlSessionURL() string { host := strings.TrimSpace(e.config.Spec.Server.Bind.Host) switch host { diff --git a/internal/server/tty_darwin.go b/internal/server/tty_darwin.go index 2ffe22b3..d79db046 100644 --- a/internal/server/tty_darwin.go +++ b/internal/server/tty_darwin.go @@ -11,7 +11,7 @@ import ( func (e *KubePiServer) startTty() { cmd := "gotty" - params := []string{"--permit-write", "bash", "init-kube.sh"} + params := []string{"--address", webkubectlBindAddress, "--permit-write", "bash", "init-kube.sh"} go func() { c := exec.Command(cmd, params...) c.Env = append(os.Environ(), "KUBEPI_WEBKUBECTL_SESSION_URL="+e.localWebkubectlSessionURL()) diff --git a/internal/server/tty_linux.go b/internal/server/tty_linux.go index a8fd6af1..36f12d54 100644 --- a/internal/server/tty_linux.go +++ b/internal/server/tty_linux.go @@ -11,7 +11,7 @@ import ( func (e *KubePiServer) startTty() { cmd := "gotty" - params := []string{"--permit-write", "unshare", "--fork", "--pid", "--mount-proc", "--mount", "bash", "init-kube.sh"} + params := []string{"--address", webkubectlBindAddress, "--permit-write", "unshare", "--fork", "--pid", "--mount-proc", "--mount", "bash", "init-kube.sh"} go func() { c := exec.Command(cmd, params...) c.Env = append(os.Environ(), "KUBEPI_WEBKUBECTL_SESSION_URL="+e.localWebkubectlSessionURL()) diff --git a/thirdparty/gotty/server/options.go b/thirdparty/gotty/server/options.go index 89650a61..d06f0334 100644 --- a/thirdparty/gotty/server/options.go +++ b/thirdparty/gotty/server/options.go @@ -8,7 +8,7 @@ import ( ) type Options struct { - Address string `hcl:"address" flagName:"address" flagSName:"a" flagDescribe:"IP address to listen" default:"0.0.0.0"` + Address string `hcl:"address" flagName:"address" flagSName:"a" flagDescribe:"IP address to listen" default:"127.0.0.1"` Port string `hcl:"port" flagName:"port" flagSName:"p" flagDescribe:"Port number to liten" default:"8080"` PermitWrite bool `hcl:"permit_write" flagName:"permit-write" flagSName:"w" flagDescribe:"Permit clients to write to the TTY (BE CAREFUL)" default:"false"` EnableBasicAuth bool `hcl:"enable_basic_auth" default:"false"` @@ -102,7 +102,7 @@ type HtermPrefernces struct { UserCss string `hcl:"user_css" json:"user-css,omitempty"` } -//RedisOptions contain whether use redis and redis connection options +// RedisOptions contain whether use redis and redis connection options type RedisOptions struct { UseRedisTokenCache string `hcl:"use_redis_token_cache" flagName:"use-redis-token-cache" flagDescribe:"if true,will use redis cache token;if false,will use memory cache token." default:"false"` // host:port address. @@ -131,7 +131,7 @@ type RedisOptions struct { MaxConnAge time.Duration `hcl:"redis_max_conn_age" flagName:"redis-max-conn-age" flagDescribe:"redis connection age at which client retires (closes) the connection" default:"0"` } -//Convert RedisOptions to redis-go Options +// Convert RedisOptions to redis-go Options func (ro *RedisOptions) Convert() *redis.Options { return &redis.Options{ Addr: ro.Addr,