Compare commits

..

No commits in common. "main" and "why" have entirely different histories.
main ... why

13 changed files with 70 additions and 310 deletions

View File

@ -8,7 +8,3 @@
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,18 +8,11 @@ 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
``` ```
## Installing ## Running
Executing `make install` will install and start podterminal as a systemd service, including an example config file. To run the built binary, just execute it as root. You will have to have Podman installed and its socket enabled.
Currently you have to set the Image, port etc. directly in the Source Code, however that should be eventually moved into
## Usage a config file.
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,20 +9,19 @@ 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: git.jmbit.de/jmb/webtop-plus:latest image: lscr.io/linuxserver/webtop
# Maximum age of Session # Maximum age of Session
maxage: 0 maxage: 10800
# 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
# Overrides UID/GID from tar archive to container user skel_chown: true
skel_chown: false # Currently useless
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,7 +25,6 @@ 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,7 +2,6 @@ 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"
@ -10,8 +9,6 @@ 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
@ -53,30 +50,15 @@ 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
for i < 50 {
time.Sleep(100 * time.Millisecond)
container, err := containers.Inspect(conn, id, nil) container, err := containers.Inspect(conn, id, nil)
if err != nil { if err != nil {
log.Println("Could not get IP of container", err) log.Println("Could not get IP of container", err)
return "", err return "", err
} }
ip = container.NetworkSettings.IPAddress ip := container.NetworkSettings.IPAddress
log.Println(ip)
if len(ip) > 5 {
break
}
i++
}
if i > 50 {
log.Println("timed out waiting for IP")
}
return ip, err return ip, err
} }

View File

@ -1,18 +1,10 @@
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 {
@ -21,28 +13,3 @@ 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,6 +3,7 @@ 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"
@ -13,8 +14,7 @@ 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,12 +25,22 @@ 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 {
for {
log.Println("Downloading Container image ", viper.GetString("image")) log.Println("Downloading Container image ", viper.GetString("image"))
image := viper.GetString("image") image := viper.GetString("image")
conn := Socket conn := Socket
@ -39,10 +49,7 @@ func PullImage() error {
log.Println(err) log.Println(err)
return err return err
} }
return nil
time.Sleep(1 * time.Hour)
}
} }
// 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)
@ -51,8 +58,11 @@ func Cleanup() error {
containerList := containerList() containerList := containerList()
for _, container := range containerList { for _, container := range containerList {
for _, ctid := range OldContainers { now := time.Now()
if container.ID == ctid { maxAge := time.Second * time.Duration(viper.GetInt("maxAge"))
containerAge := now.Sub(container.Created)
if containerAge > maxAge {
err := containers.Kill(Socket, container.ID, nil) err := containers.Kill(Socket, container.ID, nil)
if err != nil { if err != nil {
log.Println(err) log.Println(err)
@ -65,8 +75,6 @@ func Cleanup() error {
} }
} }
} }
}
return nil return nil
} }

View File

@ -1,13 +1,10 @@
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"
@ -68,36 +65,7 @@ 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,29 +5,22 @@ 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", "git.jmbit.de/jmb/webtop-plus:latest") viper.SetDefault("image", "lscr.io/linuxserver/webtop")
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_user", "") viper.SetDefault("skel_owner", "abc")
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",

View File

@ -1,18 +0,0 @@
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,14 +3,12 @@ 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"
) )
@ -29,15 +27,6 @@ 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
@ -45,48 +34,33 @@ 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)
sessionID := ""
if session.Get("ct") == nil {
session.Set("ready", false)
session.Save() session.Save()
sessionID := session.ID()
if session.Get("ct") == nil {
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)
err = waitForAnswer(fmt.Sprintf("http://%s:%d", ctip, viper.GetInt("container_port"))) proxies[ct], err = createReverseProxy(fmt.Sprintf("http://%s:3000", ctip))
proxies[ct], err = createReverseProxy(
fmt.Sprintf("http://%s:%d", ctip, viper.GetInt("container_port")),
)
if err != nil { if err != nil {
c.HTML( c.HTML(
@ -94,65 +68,23 @@ 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
} }
if err != nil { session.Set("ct", ct)
c.HTML(
500,
"Error",
fmt.Sprintf("[%s] Timed out waiting for Container: %v", sessionID, err),
)
session.Delete("ct")
session.Save() session.Save()
c.Abort() c.Redirect(301, "/")
return
}
session.Set("ready", true)
session.Save()
c.HTML(200, "", clientReloadContent)
return
} else { } else {
if session.Get("ready").(bool) == false { sessionCT := session.Get("ct")
time.Sleep(2 * time.Second) switch sessionCT.(type) {
c.Redirect(307, "/") case string:
return
default:
c.HTML(500, "Error", "Session Container ID is not a string")
c.Abort()
} }
id := session.Get("ct").(string) id := session.Get("ct").(string)
proxy := proxies[id] proxy := proxies[id]
if proxy != nil {
proxy.ServeHTTP(c.Writer, c.Request) 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,6 +8,7 @@ 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"
) )
@ -41,7 +42,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(viper.GetString("session_key"))) store := cookie.NewStore([]byte(uuid.NewString()))
store.Options(sessions.Options{ store.Options(sessions.Options{
MaxAge: viper.GetInt("maxAge"), MaxAge: viper.GetInt("maxAge"),
}) })

View File

@ -1,60 +0,0 @@
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)
}
}