Skip to content

Commit

Permalink
Allow API versioning (#85)
Browse files Browse the repository at this point in the history
* add api v3 partial support.

* fix codecs in env.

* fix default codecs.

* split files.

* add session stats for api v3.

* fix API token env.

* fix null array in json.

* add CORS.

* save api version to labels.

* irgnore api token env.

* remove duplicate api version.

* move api version to settings.
  • Loading branch information
m1k1o authored Oct 21, 2023
1 parent 93c1e6f commit 47ee523
Show file tree
Hide file tree
Showing 9 changed files with 543 additions and 208 deletions.
3 changes: 3 additions & 0 deletions OpenApi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ components:
RoomSettings:
type: object
properties:
api_version:
type: number
description: if not set, version is taken from neko_image
name:
type: string
example: foobar
Expand Down
6 changes: 6 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,12 @@ export interface RoomResources {
* @interface RoomSettings
*/
export interface RoomSettings {
/**
*
* @type {number}
* @memberof RoomSettings
*/
'api_version'?: number;
/**
*
* @type {string}
Expand Down
7 changes: 7 additions & 0 deletions internal/config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Server struct {
Key string
Bind string
Proxy bool
CORS bool
PProf bool

Admin Admin
Expand All @@ -46,6 +47,11 @@ func (Server) Init(cmd *cobra.Command) error {
return err
}

cmd.PersistentFlags().Bool("cors", false, "enable CORS")
if err := viper.BindPFlag("cors", cmd.PersistentFlags().Lookup("cors")); err != nil {
return err
}

cmd.PersistentFlags().Bool("pprof", false, "enable pprof endpoint available at /debug/pprof")
if err := viper.BindPFlag("pprof", cmd.PersistentFlags().Lookup("pprof")); err != nil {
return err
Expand Down Expand Up @@ -86,6 +92,7 @@ func (s *Server) Set() {
s.Key = viper.GetString("key")
s.Bind = viper.GetString("bind")
s.Proxy = viper.GetBool("proxy")
s.CORS = viper.GetBool("cors")
s.PProf = viper.GetBool("pprof")

s.Admin.Static = viper.GetString("admin.static")
Expand Down
49 changes: 34 additions & 15 deletions internal/room/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import (
var labelRegex = regexp.MustCompile(`^[a-z0-9.-]+$`)

type RoomLabels struct {
Name string
URL string
Mux bool
Epr EprPorts
NekoImage string
Name string
URL string
Mux bool
Epr EprPorts

NekoImage string
ApiVersion int

BrowserPolicy *BrowserPolicyLabels
UserDefined map[string]string
Expand All @@ -40,11 +42,6 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
//return nil, fmt.Errorf("damaged container labels: url not found")
}

nekoImage, ok := labels["m1k1o.neko_rooms.neko_image"]
if !ok {
return nil, fmt.Errorf("damaged container labels: neko_image not found")
}

var mux bool
var epr EprPorts

Expand Down Expand Up @@ -88,6 +85,21 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
}
}

nekoImage, ok := labels["m1k1o.neko_rooms.neko_image"]
if !ok {
return nil, fmt.Errorf("damaged container labels: neko_image not found")
}

apiVersion := 2 // default, prior to api versioning
apiVersionStr, ok := labels["m1k1o.neko_rooms.api_version"]
if ok {
var err error
apiVersion, err = strconv.Atoi(apiVersionStr)
if err != nil {
return nil, err
}
}

var browserPolicy *BrowserPolicyLabels
if val, ok := labels["m1k1o.neko_rooms.browser_policy"]; ok && val == "true" {
policyType, ok := labels["m1k1o.neko_rooms.browser_policy.type"]
Expand Down Expand Up @@ -115,11 +127,13 @@ func (manager *RoomManagerCtx) extractLabels(labels map[string]string) (*RoomLab
}

return &RoomLabels{
Name: name,
URL: url,
NekoImage: nekoImage,
Mux: mux,
Epr: epr,
Name: name,
URL: url,
Mux: mux,
Epr: epr,

NekoImage: nekoImage,
ApiVersion: apiVersion,

BrowserPolicy: browserPolicy,
UserDefined: userDefined,
Expand All @@ -134,6 +148,11 @@ func (manager *RoomManagerCtx) serializeLabels(labels RoomLabels) map[string]str
"m1k1o.neko_rooms.neko_image": labels.NekoImage,
}

// api version 2 is currently default
if labels.ApiVersion != 2 {
labelsMap["m1k1o.neko_rooms.api_version"] = fmt.Sprintf("%d", labels.ApiVersion)
}

if labels.Mux && labels.Epr.Min == labels.Epr.Max {
labelsMap["m1k1o.neko_rooms.mux"] = fmt.Sprintf("%d", labels.Epr.Min)
} else {
Expand Down
145 changes: 128 additions & 17 deletions internal/room/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/docker/cli/opts"
dockerTypes "github.com/docker/docker/api/types"
Expand Down Expand Up @@ -248,6 +250,40 @@ func (manager *RoomManagerCtx) Create(ctx context.Context, settings types.RoomSe

isPrivilegedImage, _ := utils.ArrayIn(settings.NekoImage, manager.config.NekoPrivilegedImages)

// if api version is not set, try to detect it
if settings.ApiVersion == 0 {
inspect, _, err := manager.client.ImageInspectWithRaw(ctx, settings.NekoImage)
if err != nil {
return "", err
}

// based on image label
if val, ok := inspect.Config.Labels["m1k1o.neko_rooms.api_version"]; ok {
var err error
settings.ApiVersion, err = strconv.Atoi(val)
if err != nil {
return "", err
}
} else

// based on opencontainers image url label
if val, ok := inspect.Config.Labels["org.opencontainers.image.url"]; ok {
switch val {
case "https://github.com/m1k1o/neko":
settings.ApiVersion = 2
case "https://github.com/demodesk/neko":
settings.ApiVersion = 3
}
} else

// unable to detect api version
{
// TODO: this should be removed in future, but since we have a lot of v2 images, we need to support it
log.Warn().Str("image", settings.NekoImage).Msg("unable to detect api version, fallback to v2")
settings.ApiVersion = 2
}
}

// TODO: Check if path name exists.
roomName := settings.Name
if roomName == "" {
Expand Down Expand Up @@ -315,10 +351,12 @@ func (manager *RoomManagerCtx) Create(ctx context.Context, settings types.RoomSe
}

labels := manager.serializeLabels(RoomLabels{
Name: roomName,
Mux: manager.config.Mux,
Epr: epr,
NekoImage: settings.NekoImage,
Name: roomName,
Mux: manager.config.Mux,
Epr: epr,

NekoImage: settings.NekoImage,
ApiVersion: settings.ApiVersion,

BrowserPolicy: browserPolicyLabels,
UserDefined: settings.Labels,
Expand Down Expand Up @@ -394,11 +432,16 @@ func (manager *RoomManagerCtx) Create(ctx context.Context, settings types.RoomSe
// Set environment variables
//

env := settings.ToEnv(manager.config, types.PortSettings{
FrontendPort: frontendPort,
EprMin: epr.Min,
EprMax: epr.Max,
})
env, err := settings.ToEnv(
manager.config,
types.PortSettings{
FrontendPort: frontendPort,
EprMin: epr.Min,
EprMax: epr.Max,
})
if err != nil {
return "", err
}

//
// Set browser policies
Expand Down Expand Up @@ -830,7 +873,7 @@ func (manager *RoomManagerCtx) GetSettings(ctx context.Context, id string) (*typ
settings.MaxConnections = 0
}

err = settings.FromEnv(container.Config.Env)
err = settings.FromEnv(labels.ApiVersion, container.Config.Env)
return &settings, err
}

Expand All @@ -840,22 +883,90 @@ func (manager *RoomManagerCtx) GetStats(ctx context.Context, id string) (*types.
return nil, err
}

settings := types.RoomSettings{}
err = settings.FromEnv(container.Config.Env)
labels, err := manager.extractLabels(container.Config.Labels)
if err != nil {
return nil, err
}

output, err := manager.containerExec(ctx, id, []string{
"wget", "-q", "-O-", "http://127.0.0.1:8080/stats?pwd=" + url.QueryEscape(settings.AdminPass),
})
settings := types.RoomSettings{}
err = settings.FromEnv(labels.ApiVersion, container.Config.Env)
if err != nil {
return nil, err
}

var stats types.RoomStats
if err := json.Unmarshal([]byte(output), &stats); err != nil {
return nil, err
switch labels.ApiVersion {
case 2:
output, err := manager.containerExec(ctx, id, []string{
"wget", "-q", "-O-", "http://127.0.0.1:8080/stats?pwd=" + url.QueryEscape(settings.AdminPass),
})
if err != nil {
return nil, err
}

if err := json.Unmarshal([]byte(output), &stats); err != nil {
return nil, err
}
case 3:
output, err := manager.containerExec(ctx, id, []string{
"wget", "-q", "-O-", "http://127.0.0.1:8080/api/sessions?token=" + url.QueryEscape(settings.AdminPass),
})
if err != nil {
return nil, err
}

var sessions []struct {
ID string `json:"id"`
Profile struct {
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
} `json:"profile"`
State struct {
IsConnected bool `json:"is_connected"`
NotConnectedSince *time.Time `json:"not_connected_since"`
} `json:"state"`
}

if err := json.Unmarshal([]byte(output), &sessions); err != nil {
return nil, err
}

// create empty array so that it's not null in json
stats.Members = []*types.RoomMember{}

for _, session := range sessions {
if session.State.IsConnected {
stats.Connections++
// append members
stats.Members = append(stats.Members, &types.RoomMember{
ID: session.ID,
Name: session.Profile.Name,
Admin: session.Profile.IsAdmin,
Muted: false, // not supported
})
} else if session.State.NotConnectedSince != nil {
// populate last admin left time
if session.Profile.IsAdmin && (stats.LastAdminLeftAt == nil || (*session.State.NotConnectedSince).After(*stats.LastAdminLeftAt)) {
stats.LastAdminLeftAt = session.State.NotConnectedSince
}
// populate last user left time
if !session.Profile.IsAdmin && (stats.LastUserLeftAt == nil || (*session.State.NotConnectedSince).After(*stats.LastUserLeftAt)) {
stats.LastUserLeftAt = session.State.NotConnectedSince
}
}
}

// parse started time
if container.State.StartedAt != "" {
stats.ServerStartedAt, err = time.Parse(time.RFC3339, container.State.StartedAt)
if err != nil {
return nil, err
}
}

// TODO: settings & host
default:
return nil, fmt.Errorf("unsupported API version: %d", labels.ApiVersion)
}

return &stats, nil
Expand Down
20 changes: 12 additions & 8 deletions internal/server/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,18 @@ func New(ApiManager types.ApiManager, roomConfig *config.Room, config *config.Se
router.Use(middleware.Recoverer) // Recover from panics without crashing server

// Basic CORS
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
if config.CORS {
// for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing
router.Use(cors.Handler(cors.Options{
AllowOriginFunc: func(r *http.Request, origin string) bool {
return true
},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
}

// mount pprof endpoint
if config.PProf {
Expand Down
Loading

0 comments on commit 47ee523

Please sign in to comment.