Compare commits

...

5 Commits
why ... main

Author SHA1 Message Date
Johannes Bülow d12722cb6b
weird nil pointer deref
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-02-05 09:45:28 +01:00
Johannes Bülow 37f2801d53
semi-alpha version
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-02-03 11:11:10 +01:00
Johannes Bülow 7ff1ec233d
before I mess it up by changing the way timeouts work 2024-02-02 18:47:45 +01:00
Johannes Bülow e5c78de08f
now re-pulls Image regularily
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-02-02 10:41:51 +01:00
Johannes Bülow 0e21438cc8
disabled skel functionality for now
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-02-01 17:32:46 +01:00
13 changed files with 311 additions and 71 deletions

View File

@ -8,3 +8,7 @@
5533801d-70e3-4c21-9942-82d20930c789 5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789 5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789 5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789
5533801d-70e3-4c21-9942-82d20930c789

View File

@ -8,11 +8,18 @@ Users to use Webtop in their own, separate session. A good illustration is this:
To build the project, you can just use "make" To build the project, you can just use "make"
On Debian 12, you will need the following dependencies: On Debian 12, you will need the following dependencies:
``` ```sh
apt install -y git wget podman make gcc libgpgme-dev build-essential pkgconf pkgconf-bin libdevmapper-dev libbtrfs-dev apt install -y git wget podman make gcc libgpgme-dev build-essential pkgconf pkgconf-bin libdevmapper-dev libbtrfs-dev
``` ```
## Running ## Installing
To run the built binary, just execute it as root. You will have to have Podman installed and its socket enabled. Executing `make install` will install and start podterminal as a systemd service, including an example config file.
Currently you have to set the Image, port etc. directly in the Source Code, however that should be eventually moved into
a config file. ## Usage
There is some basic Documentation in the config file to explain the usage of the keys. For more in-depth explanations
feel free to contact me, and I will add it to the documentation
## Known Bugs
- Images need to be pulled first as root
- Not compatible with default KASM Docker images (needs http WSS access)
- reloading browser on startup might be necessary, especially if the target image is slow to start

View File

@ -9,19 +9,20 @@ envvars:
# HTTP_PROXY: 192.168.0.10 # HTTP_PROXY: 192.168.0.10
# Container Image you want to use # Container Image you want to use
image: lscr.io/linuxserver/webtop image: git.jmbit.de/jmb/webtop-plus:latest
# Maximum age of Session # Maximum age of Session
maxage: 10800 maxage: 0
# Port Podterminal should listen to # Port Podterminal should listen to
port: 80 port: 80
# Files that will be copied into container on startup # Files that will be copied into container on startup
skel: /etc/podterminal/skel.tar.gz skel: /etc/podterminal/skel.tar.gz
skel_target: /config skel_target: /config
skel_chown: true # Overrides UID/GID from tar archive to container user
# Currently useless skel_chown: false
skel_owner: "abc"
ssl: false ssl: false
ssl_cert: /etc/ssl/certs/ssl-cert-snakeoil.pem ssl_cert: /etc/ssl/certs/ssl-cert-snakeoil.pem
ssl_cert_key: /etc/ssl/private/ssl-cert-snakeoil.key ssl_cert_key: /etc/ssl/private/ssl-cert-snakeoil.key
# Blocks accessing /files and /files/socket.io to avoid File transfer # Blocks accessing /files and /files/socket.io to avoid File transfer
block_filebrowser: false block_filebrowser: false
# Delete running containers when restarting podterminal
timeout_on_restart: true

View File

@ -25,6 +25,7 @@ func main() {
log.Println("Dropped Privileges") log.Println("Dropped Privileges")
g.Go(pods.GarbageCollector) g.Go(pods.GarbageCollector)
g.Go(pods.PullImage) g.Go(pods.PullImage)
g.Go(web.IdleSessionCleanup)
// prevent main thread from dying // prevent main thread from dying
if err := g.Wait(); err != nil { if err := g.Wait(); err != nil {

View File

@ -2,6 +2,7 @@ package pods
import ( import (
"log" "log"
"time"
"github.com/containers/podman/v4/pkg/bindings/containers" "github.com/containers/podman/v4/pkg/bindings/containers"
"github.com/containers/podman/v4/pkg/specgen" "github.com/containers/podman/v4/pkg/specgen"
@ -9,6 +10,8 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// CreateContainer connects to the Podman socket defined in the "Socket" variable in this scope
// and creates a new container using the Image defined in the config file
func CreateContainer() (string, error) { func CreateContainer() (string, error) {
image := viper.GetString("image") image := viper.GetString("image")
conn := Socket conn := Socket
@ -50,15 +53,30 @@ func DestroyContainer(id string) error {
return nil return nil
} }
// GetContainerIP returns a
func GetContainerIP(id string) (string, error) { func GetContainerIP(id string) (string, error) {
conn := Socket conn := Socket
var ip string
var err error
i := 0
container, err := containers.Inspect(conn, id, nil) for i < 50 {
if err != nil { time.Sleep(100 * time.Millisecond)
log.Println("Could not get IP of container", err) container, err := containers.Inspect(conn, id, nil)
return "", err if err != nil {
log.Println("Could not get IP of container", err)
return "", err
}
ip = container.NetworkSettings.IPAddress
log.Println(ip)
if len(ip) > 5 {
break
}
i++
}
if i > 50 {
log.Println("timed out waiting for IP")
} }
ip := container.NetworkSettings.IPAddress
return ip, err return ip, err
} }

View File

@ -1,10 +1,18 @@
package pods package pods
import ( import (
"log"
"time" "time"
"github.com/containers/podman/v4/pkg/bindings/volumes"
"github.com/spf13/viper"
) )
// GarbageCollector is a goroutine that cleans up old Containers
func GarbageCollector() error { func GarbageCollector() error {
if viper.GetBool("timeout_on_restart") {
timeoutExistingContainers()
}
for { for {
err := Cleanup() err := Cleanup()
if err != nil { if err != nil {
@ -13,3 +21,28 @@ func GarbageCollector() error {
time.Sleep(time.Minute * 10) time.Sleep(time.Minute * 10)
} }
} }
func timeoutExistingContainers() {
var oldContainers []string
for _, container := range containerList() {
oldContainers = append(oldContainers, container.ID)
}
OldContainers = append(OldContainers, oldContainers...)
log.Println("old Containers: ", oldContainers)
}
func pruneVolumes() error {
results, err := volumes.Prune(Socket, nil)
if err != nil {
return err
}
resultLen := len(results)
for i, result := range results {
log.Printf("[%d/%d] %s %d MB", i, resultLen, result.Id, result.Size/1024/1024)
}
return nil
}

View File

@ -3,7 +3,6 @@ package pods
import ( import (
"context" "context"
"log" "log"
"net"
"time" "time"
"github.com/containers/podman/v4/pkg/bindings" "github.com/containers/podman/v4/pkg/bindings"
@ -14,7 +13,8 @@ import (
) )
var Socket context.Context var Socket context.Context
var rawSocket net.Conn
var OldContainers []string
func socketConnection() context.Context { func socketConnection() context.Context {
uri := "unix:///run/podman/podman.sock" uri := "unix:///run/podman/podman.sock"
@ -25,31 +25,24 @@ func socketConnection() context.Context {
return conn return conn
} }
func rawConnection() net.Conn {
connection, err := net.Dial("unix", "unix:///run/podman/podman.sock")
if err != nil {
log.Println(
"Could not establish raw UNIX socket connection, certain features will not work properly",
)
}
return connection
}
func ConnectSocket() { func ConnectSocket() {
Socket = socketConnection() Socket = socketConnection()
rawSocket = rawConnection()
} }
func PullImage() error { func PullImage() error {
log.Println("Downloading Container image ", viper.GetString("image")) for {
image := viper.GetString("image") log.Println("Downloading Container image ", viper.GetString("image"))
conn := Socket image := viper.GetString("image")
_, err := images.Pull(conn, image, nil) conn := Socket
if err != nil { _, err := images.Pull(conn, image, nil)
log.Println(err) if err != nil {
return err log.Println(err)
return err
}
time.Sleep(1 * time.Hour)
} }
return nil
} }
// Cleanup deletes Containers older than the specified maximum Age (Equal to session cookie maximum age) // Cleanup deletes Containers older than the specified maximum Age (Equal to session cookie maximum age)
@ -58,22 +51,21 @@ func Cleanup() error {
containerList := containerList() containerList := containerList()
for _, container := range containerList { for _, container := range containerList {
now := time.Now() for _, ctid := range OldContainers {
maxAge := time.Second * time.Duration(viper.GetInt("maxAge")) if container.ID == ctid {
containerAge := now.Sub(container.Created) err := containers.Kill(Socket, container.ID, nil)
if containerAge > maxAge { if err != nil {
log.Println(err)
err := containers.Kill(Socket, container.ID, nil) return err
if err != nil { }
log.Println(err) _, err = containers.Remove(Socket, container.ID, nil)
return err if err != nil {
} log.Println(err)
_, err = containers.Remove(Socket, container.ID, nil) return err
if err != nil { }
log.Println(err)
return err
} }
} }
} }
return nil return nil
} }

View File

@ -1,10 +1,13 @@
package pods package pods
import ( import (
"bytes"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/containers/podman/v4/pkg/bindings" "github.com/containers/podman/v4/pkg/bindings"
"github.com/containers/podman/v4/pkg/bindings/containers" "github.com/containers/podman/v4/pkg/bindings/containers"
@ -65,7 +68,36 @@ func CopySkelToContainer(id string) error {
return err return err
} }
log.Println(response.StatusCode, response.Body) log.Println(response.StatusCode, response.Body)
if viper.GetString("skel_user") != "" {
chownSkel(id)
}
// Manually chown the Directory, because podmans chown is sometimes unreliable // Manually chown the Directory, because podmans chown is sometimes unreliable
return response.Process(nil) return response.Process(nil)
} }
func chownSkel(id string) {
command := fmt.Sprintf(
"chown -R %s %s \n\n",
viper.GetString("skel_user"),
viper.GetString("skel_target"),
)
done := make(chan bool)
detachKeys := "\n\n"
commandReader := strings.NewReader(command)
var output bytes.Buffer
outputWriter := io.Writer(&output)
options := &containers.AttachOptions{
DetachKeys: &detachKeys,
}
err := containers.Attach(Socket, id, commandReader, outputWriter, outputWriter, done, options)
if err != nil {
log.Printf("Error: %v \n %s\n", err, output.String())
} else {
log.Println(output.String())
}
}

View File

@ -5,22 +5,29 @@ import (
"os" "os"
"github.com/spf13/viper" "github.com/spf13/viper"
"git.jmbit.de/jmb/podterminal/utils"
) )
func readConfigFile() { func readConfigFile() {
sessionKey, _ := utils.RandomString(64)
log.Println("Reading Config") log.Println("Reading Config")
viper.SetConfigFile("/etc/podterminal/config.yaml") viper.SetConfigFile("/etc/podterminal/config.yaml")
viper.SetDefault("port", 80) viper.SetDefault("port", 80)
viper.SetDefault("ip_addr", "0.0.0.0") viper.SetDefault("ip_addr", "0.0.0.0")
viper.SetDefault("image", "lscr.io/linuxserver/webtop") viper.SetDefault("image", "git.jmbit.de/jmb/webtop-plus:latest")
viper.SetDefault("maxAge", 10800) viper.SetDefault("maxAge", 10800)
viper.SetDefault("dri", false) viper.SetDefault("dri", false)
viper.SetDefault("dir_node", "/dev/dri/renderD128") viper.SetDefault("dir_node", "/dev/dri/renderD128")
viper.SetDefault("skel", "/etc/podterminal/skel.tar.gz") viper.SetDefault("skel", "/etc/podterminal/skel.tar.gz")
viper.SetDefault("skel_chown", false) viper.SetDefault("skel_chown", false)
viper.SetDefault("skel_target", "/") viper.SetDefault("skel_target", "/")
viper.SetDefault("skel_owner", "abc") viper.SetDefault("skel_user", "")
viper.SetDefault("block_filebrowser", false) viper.SetDefault("block_filebrowser", false)
viper.SetDefault("session_key", sessionKey)
viper.SetDefault("container_port", 3000)
viper.SetDefault("container_protocol", "http")
viper.SetDefault("timeout_on_restart", true)
viper.SetDefault("envvars", viper.SetDefault("envvars",
map[string]string{ map[string]string{
"CUSTOM_USER": "user", "CUSTOM_USER": "user",

18
web/responses.go Normal file
View File

@ -0,0 +1,18 @@
package web
var clientReloadContent = `
<!DOCTYPE html>
<html>
<head>
<title> Reloading </title>
</head>
<body>
reloading
</body>
<script>
setTimeout(() => {
document.location.reload();
}, 3000);
</script>
</html>
`

View File

@ -3,12 +3,14 @@ package web
import ( import (
"fmt" "fmt"
"log" "log"
"net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"time" "time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/viper"
"git.jmbit.de/jmb/podterminal/pods" "git.jmbit.de/jmb/podterminal/pods"
) )
@ -27,6 +29,15 @@ func createReverseProxy(backendService string) (*httputil.ReverseProxy, error) {
request.SetURL(backendURL) request.SetURL(backendURL)
request.Out.Host = request.In.Host request.Out.Host = request.In.Host
}, },
// ModifyResponse: func(response *http.Response) error {
// if response.StatusCode == http.StatusBadGateway {
// waitForAnswer(backendURL.String())
// response.StatusCode = 200
// response.Header.Set("Location", "/")
// }
// return nil
// },
} }
return proxy, err return proxy, err
@ -34,33 +45,48 @@ func createReverseProxy(backendService string) (*httputil.ReverseProxy, error) {
func containerProxy(c *gin.Context) { func containerProxy(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
session.Save() sessionID := ""
sessionID := session.ID()
if session.Get("ct") == nil { if session.Get("ct") == nil {
session.Set("ready", false)
session.Save()
log.Println("Creating Container for Session ", sessionID) log.Println("Creating Container for Session ", sessionID)
ct, err := pods.CreateContainer() ct, err := pods.CreateContainer()
session.Set("ct", ct)
session.Save()
if err != nil { if err != nil {
c.HTML(500, "Error", fmt.Sprintf("[%s] Could not create Container: %v", sessionID, err)) c.HTML(500, "Error", fmt.Sprintf("[%s] Could not create Container: %v", sessionID, err))
session.Delete("ct")
session.Save()
c.Abort() c.Abort()
return
} }
err = pods.StartContainer(ct) err = pods.StartContainer(ct)
if err != nil { if err != nil {
c.HTML(500, "Error", fmt.Sprintf("[%s] Could not start Container: %v", sessionID, err)) c.HTML(500, "Error", fmt.Sprintf("[%s] Could not start Container: %v", sessionID, err))
session.Delete("ct")
session.Save()
c.Abort() c.Abort()
return
} }
// Hack to wait for Container to start up and get assigned an IP
time.Sleep(3 * time.Second)
ctip, err := pods.GetContainerIP(ct) ctip, err := pods.GetContainerIP(ct)
if err != nil { if err != nil {
c.HTML(500, "Error", fmt.Sprintf("[%s] Could not get Container ip: %v", sessionID, err)) c.HTML(500, "Error", fmt.Sprintf("[%s] Could not get Container ip: %v", sessionID, err))
session.Delete("ct")
session.Save()
c.Abort() c.Abort()
return
} }
// Soft fail Skel // Soft fail Skel
_ = pods.CopySkelToContainer(ct) // _ = pods.CopySkelToContainer(ct)
proxies[ct], err = createReverseProxy(fmt.Sprintf("http://%s:3000", ctip)) err = waitForAnswer(fmt.Sprintf("http://%s:%d", ctip, viper.GetInt("container_port")))
proxies[ct], err = createReverseProxy(
fmt.Sprintf("http://%s:%d", ctip, viper.GetInt("container_port")),
)
if err != nil { if err != nil {
c.HTML( c.HTML(
@ -68,23 +94,65 @@ func containerProxy(c *gin.Context) {
"Error", "Error",
fmt.Sprintf("[%s] Could not create Container Proxy: %v", sessionID, err), fmt.Sprintf("[%s] Could not create Container Proxy: %v", sessionID, err),
) )
session.Delete("ct")
session.Save()
c.Abort() c.Abort()
return
} }
session.Set("ct", ct) if err != nil {
session.Save() c.HTML(
c.Redirect(301, "/") 500,
} else { "Error",
sessionCT := session.Get("ct") fmt.Sprintf("[%s] Timed out waiting for Container: %v", sessionID, err),
switch sessionCT.(type) { )
case string: session.Delete("ct")
session.Save()
default:
c.HTML(500, "Error", "Session Container ID is not a string")
c.Abort() c.Abort()
return
}
session.Set("ready", true)
session.Save()
c.HTML(200, "", clientReloadContent)
return
} else {
if session.Get("ready").(bool) == false {
time.Sleep(2 * time.Second)
c.Redirect(307, "/")
return
} }
id := session.Get("ct").(string) id := session.Get("ct").(string)
proxy := proxies[id] proxy := proxies[id]
proxy.ServeHTTP(c.Writer, c.Request) if proxy != nil {
proxy.ServeHTTP(c.Writer, c.Request)
} else {
session.Delete("ct")
session.Delete("ready")
session.Save()
c.Abort()
return
}
} }
} }
func waitForAnswer(url string) error {
retries := 0
var err error
for retries < 50 {
err = nil
response, err := http.Get(url)
if err != nil {
log.Printf("Error connecting to %s: %v", url, err)
}
if response.StatusCode == 200 {
return nil
}
retries++
time.Sleep(500 * time.Millisecond)
}
log.Println("Timed out waiting for Container")
return err
}

View File

@ -8,7 +8,6 @@ import (
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -42,7 +41,7 @@ func setupRouter() *gin.Engine {
gin.ForceConsoleColor() gin.ForceConsoleColor()
gin.SetMode("release") gin.SetMode("release")
router := gin.New() router := gin.New()
store := cookie.NewStore([]byte(uuid.NewString())) store := cookie.NewStore([]byte(viper.GetString("session_key")))
store.Options(sessions.Options{ store.Options(sessions.Options{
MaxAge: viper.GetInt("maxAge"), MaxAge: viper.GetInt("maxAge"),
}) })

60
web/sessionAging.go Normal file
View File

@ -0,0 +1,60 @@
package web
import (
"log"
"time"
"github.com/spf13/viper"
"git.jmbit.de/jmb/podterminal/pods"
)
type sessionData struct {
lastAccess *time.Time
sessionID string
}
var invalidSessions []string
var sessionLastAccess map[string]*sessionData
func initSessionAging() error {
sessionLastAccess = make(map[string]*sessionData)
return nil
}
func updateSession(id string, sessionID string) {
nowTime := time.Now()
sessionLastAccess[id].lastAccess = &nowTime
sessionLastAccess[id].sessionID = sessionID
}
func deleteIdleSessions() {
idleTimout := viper.GetInt("session_timeout")
tenMinutesAgo := -time.Duration(idleTimout) * time.Second
oldAge := time.Now().Add(tenMinutesAgo)
for session, sessionData := range sessionLastAccess {
log.Printf("Session %s last connected at %s", session, sessionData.lastAccess.String())
if oldAge.After(*sessionData.lastAccess) {
pods.DestroyContainer(session)
invalidSessions = append(invalidSessions, sessionData.sessionID)
// Delete Proxy entry to avoid 502s
delete(proxies, session)
}
}
}
func IdleSessionCleanup() error {
err := initSessionAging()
if err != nil {
println("Could not initialize Session aging")
return err
}
for {
deleteIdleSessions()
time.Sleep(time.Duration(viper.GetInt("session_timeout")) * time.Second)
}
}