From 9eee42c673fe88a55c7fc276067915e8e09a708c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 3 Dec 2025 20:31:38 +0100 Subject: [PATCH 01/24] Initial api key store implementation --- cmd/server/main.go | 4 +- cmd/server/migrate_json.go | 4 +- go.mod | 9 +- go.sum | 33 +- pkg/auth/hash.go | 73 +++++ pkg/auth/key.go | 51 ++++ pkg/database/apikeys.go | 211 +++++++++++++ pkg/database/database.go | 30 +- .../migrations/001_initial_schema.down.sql | 12 +- .../migrations/001_initial_schema.up.sql | 36 +++ pkg/database/permissions.go | 57 ++++ pkg/instance/instance.go | 10 +- pkg/manager/manager.go | 4 +- pkg/server/handlers.go | 14 +- pkg/server/handlers_auth.go | 284 ++++++++++++++++++ pkg/server/middleware.go | 231 +++++++++++--- pkg/server/middleware_test.go | 20 +- pkg/server/routes.go | 19 +- 18 files changed, 986 insertions(+), 116 deletions(-) create mode 100644 pkg/auth/hash.go create mode 100644 pkg/auth/key.go create mode 100644 pkg/database/apikeys.go create mode 100644 pkg/database/permissions.go create mode 100644 pkg/server/handlers_auth.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 0eeca45..9431b59 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,7 +48,7 @@ func main() { cfg.CommitHash = commitHash cfg.BuildTime = buildTime - // Create the data directory if it doesn't exist + // Create data directory if it doesn't exist if cfg.Instances.AutoCreateDirs { // Create the main data directory if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { @@ -91,7 +91,7 @@ func main() { instanceManager := manager.New(&cfg, db) // Create a new handler with the instance manager - handler := server.NewHandler(instanceManager, cfg) + handler := server.NewHandler(instanceManager, cfg, db) // Setup the router with the handler r := server.SetupRouter(handler) diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go index 7ee6a2b..eb14781 100644 --- a/cmd/server/migrate_json.go +++ b/cmd/server/migrate_json.go @@ -13,7 +13,7 @@ import ( // migrateFromJSON migrates instances from JSON files to SQLite database // This is a one-time migration that runs on first startup with existing JSON files. -func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { +func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error { instancesDir := cfg.Instances.InstancesDir if instancesDir == "" { return nil // No instances directory configured @@ -76,7 +76,7 @@ func migrateFromJSON(cfg *config.AppConfig, db database.DB) error { } // migrateJSONFile migrates a single JSON file to the database -func migrateJSONFile(filename string, db database.DB) error { +func migrateJSONFile(filename string, db database.InstanceStore) error { data, err := os.ReadFile(filename) if err != nil { return fmt.Errorf("failed to read file: %w", err) diff --git a/go.mod b/go.mod index 73b77f8..de69ecf 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.24.5 require ( github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.2 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/mattn/go-sqlite3 v1.14.24 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.5 + golang.org/x/crypto v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,16 +19,12 @@ require ( github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/golang-migrate/migrate/v4 v4.19.1 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/swaggo/files v1.0.1 // indirect - go.uber.org/atomic v1.7.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 8924797..7431b29 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -16,35 +14,26 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= -github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= @@ -54,27 +43,21 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM= github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -83,6 +66,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -93,8 +78,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/auth/hash.go b/pkg/auth/hash.go new file mode 100644 index 0000000..776b851 --- /dev/null +++ b/pkg/auth/hash.go @@ -0,0 +1,73 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + // Argon2 parameters + time uint32 = 1 + memory uint32 = 64 * 1024 // 64 MB + threads uint8 = 4 + keyLen uint32 = 32 + saltLen uint32 = 16 +) + +// HashKey hashes an API key using Argon2id +func HashKey(plainTextKey string) (string, error) { + // Generate random salt + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + // Derive key using Argon2id + hash := argon2.IDKey([]byte(plainTextKey), salt, time, memory, threads, keyLen) + + // Format: $argon2id$v=19$m=65536,t=1,p=4$$ + saltB64 := base64.RawStdEncoding.EncodeToString(salt) + hashB64 := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, time, threads, saltB64, hashB64), nil +} + +// VerifyKey verifies a plain-text key against an Argon2id hash +func VerifyKey(plainTextKey, hash string) bool { + // Parse the hash format + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + + // Extract parameters + var version, time, memory, threads int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != 19 { + return false + } + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false + } + + // Decode salt and hash + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + + expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + // Compute hash of the provided key + computedHash := argon2.IDKey([]byte(plainTextKey), salt, uint32(time), uint32(memory), uint8(threads), uint32(len(expectedHash))) + + // Compare hashes using constant-time comparison + return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 +} diff --git a/pkg/auth/key.go b/pkg/auth/key.go new file mode 100644 index 0000000..211647c --- /dev/null +++ b/pkg/auth/key.go @@ -0,0 +1,51 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +type PermissionMode string + +const ( + PermissionModeAllowAll PermissionMode = "allow_all" + PermissionModePerInstance PermissionMode = "per_instance" +) + +type APIKey struct { + ID int + KeyHash string + Name string + UserID string + PermissionMode PermissionMode + ExpiresAt *int64 + Enabled bool + CreatedAt int64 + UpdatedAt int64 + LastUsedAt *int64 +} + +type KeyPermission struct { + KeyID int + InstanceID int + CanInfer bool + CanViewLogs bool +} + +// GenerateKey generates a cryptographically secure inference API key +// Format: sk-inference-<64-hex-chars> +func GenerateKey() (string, error) { + // Generate 32 random bytes + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Convert to hex (64 characters) + hexStr := hex.EncodeToString(bytes) + + // Prefix with "sk-inference-" + return fmt.Sprintf("sk-inference-%s", hexStr), nil +} diff --git a/pkg/database/apikeys.go b/pkg/database/apikeys.go new file mode 100644 index 0000000..940bc62 --- /dev/null +++ b/pkg/database/apikeys.go @@ -0,0 +1,211 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "llamactl/pkg/auth" + "time" +) + +// CreateKey inserts a new API key with permissions (transactional) +func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Insert the API key + query := ` + INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + + var expiresAt sql.NullInt64 + if key.ExpiresAt != nil { + expiresAt = sql.NullInt64{Int64: *key.ExpiresAt, Valid: true} + } + + result, err := tx.ExecContext(ctx, query, + key.KeyHash, key.Name, key.UserID, key.PermissionMode, + expiresAt, key.Enabled, key.CreatedAt, key.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to insert API key: %w", err) + } + + keyID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + key.ID = int(keyID) + + // Insert permissions if per-instance mode + if key.PermissionMode == auth.PermissionModePerInstance { + for _, perm := range permissions { + query := ` + INSERT INTO key_permissions (key_id, instance_id, can_infer, can_view_logs) + VALUES (?, ?, ?, ?) + ` + _, err := tx.ExecContext(ctx, query, perm.KeyID, perm.InstanceID, perm.CanInfer, perm.CanViewLogs) + if err != nil { + return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err) + } + } + } + + return tx.Commit() +} + +// GetKeyByID retrieves an API key by ID +func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + FROM api_keys + WHERE id = ? + ` + + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := db.QueryRowContext(ctx, query, id).Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("API key not found") + } + return nil, fmt.Errorf("failed to query API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + return &key, nil +} + +// GetUserKeys retrieves all API keys for a user +func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + FROM api_keys + WHERE user_id = ? + ORDER BY created_at DESC + ` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, fmt.Errorf("failed to query API keys: %w", err) + } + defer rows.Close() + + var keys []*auth.APIKey + for rows.Next() { + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := rows.Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + keys = append(keys, &key) + } + + return keys, nil +} + +// GetActiveKeys retrieves all enabled, non-expired API keys +func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) { + query := ` + SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + FROM api_keys + WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) + ORDER BY created_at DESC + ` + + now := time.Now().Unix() + rows, err := db.QueryContext(ctx, query, now) + if err != nil { + return nil, fmt.Errorf("failed to query active API keys: %w", err) + } + defer rows.Close() + + var keys []*auth.APIKey + for rows.Next() { + var key auth.APIKey + var expiresAt sql.NullInt64 + var lastUsedAt sql.NullInt64 + + err := rows.Scan( + &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, + &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan API key: %w", err) + } + + if expiresAt.Valid { + key.ExpiresAt = &expiresAt.Int64 + } + if lastUsedAt.Valid { + key.LastUsedAt = &lastUsedAt.Int64 + } + + keys = append(keys, &key) + } + + return keys, nil +} + +// DeleteKey removes an API key (cascades to permissions) +func (db *sqliteDB) DeleteKey(ctx context.Context, id int) error { + query := `DELETE FROM api_keys WHERE id = ?` + + result, err := db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("failed to delete API key: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("API key not found") + } + + return nil +} + +// TouchKey updates the last_used_at timestamp +func (db *sqliteDB) TouchKey(ctx context.Context, id int) error { + query := `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?` + + now := time.Now().Unix() + _, err := db.ExecContext(ctx, query, now, now, id) + if err != nil { + return fmt.Errorf("failed to update last used timestamp: %w", err) + } + + return nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go index 957fac7..98793ff 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -1,8 +1,10 @@ package database import ( + "context" "database/sql" "fmt" + "llamactl/pkg/auth" "llamactl/pkg/instance" "log" "path/filepath" @@ -11,14 +13,26 @@ import ( _ "github.com/mattn/go-sqlite3" ) -// DB defines the interface for instance persistence operations -type DB interface { +// InstanceStore defines interface for instance persistence operations +type InstanceStore interface { Save(inst *instance.Instance) error Delete(name string) error LoadAll() ([]*instance.Instance, error) Close() error } +// AuthStore defines the interface for authentication operations +type AuthStore interface { + CreateKey(ctx context.Context, key *auth.APIKey, permissions []auth.KeyPermission) error + GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) + GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) + GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) + DeleteKey(ctx context.Context, id int) error + TouchKey(ctx context.Context, id int) error + GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) + HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) +} + // Config contains database configuration settings type Config struct { // Database file path (relative to data_dir or absolute) @@ -30,13 +44,13 @@ type Config struct { ConnMaxLifetime time.Duration } -// sqliteDB wraps the database connection with configuration +// sqliteDB wraps database connection with configuration type sqliteDB struct { *sql.DB config *Config } -// Open creates a new database connection with the provided configuration +// Open creates a new database connection with provided configuration func Open(config *Config) (*sqliteDB, error) { if config == nil { return nil, fmt.Errorf("database config cannot be nil") @@ -46,10 +60,10 @@ func Open(config *Config) (*sqliteDB, error) { return nil, fmt.Errorf("database path cannot be empty") } - // Ensure the database directory exists + // Ensure that database directory exists dbDir := filepath.Dir(config.Path) if dbDir != "." && dbDir != "/" { - // Directory will be created by the manager if auto_create_dirs is enabled + // Directory will be created by manager if auto_create_dirs is enabled log.Printf("Database will be created at: %s", config.Path) } @@ -89,7 +103,7 @@ func Open(config *Config) (*sqliteDB, error) { }, nil } -// Close closes the database connection +// Close closes database connection func (db *sqliteDB) Close() error { if db.DB != nil { log.Println("Closing database connection") @@ -98,7 +112,7 @@ func (db *sqliteDB) Close() error { return nil } -// HealthCheck verifies the database is accessible +// HealthCheck verifies that database is accessible func (db *sqliteDB) HealthCheck() error { if db.DB == nil { return fmt.Errorf("database connection is nil") diff --git a/pkg/database/migrations/001_initial_schema.down.sql b/pkg/database/migrations/001_initial_schema.down.sql index 08b26e0..633814b 100644 --- a/pkg/database/migrations/001_initial_schema.down.sql +++ b/pkg/database/migrations/001_initial_schema.down.sql @@ -1,7 +1,11 @@ --- Drop indexes first -DROP INDEX IF EXISTS idx_instances_backend_type; +-- Drop API key related indexes and tables first +DROP INDEX IF EXISTS idx_key_permissions_instance_id; +DROP INDEX IF EXISTS idx_api_keys_expires_at; +DROP INDEX IF EXISTS idx_api_keys_user_id; +DROP TABLE IF EXISTS key_permissions; +DROP TABLE IF EXISTS api_keys; + +-- Drop instance related indexes and tables DROP INDEX IF EXISTS idx_instances_status; DROP INDEX IF EXISTS idx_instances_name; - --- Drop tables DROP TABLE IF EXISTS instances; diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 89eac83..2338a82 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -25,3 +25,39 @@ CREATE TABLE IF NOT EXISTS instances ( -- ----------------------------------------------------------------------------- CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_name ON instances(name); CREATE INDEX IF NOT EXISTS idx_instances_status ON instances(status); + +-- ----------------------------------------------------------------------------- +-- API Keys Table: Database-backed inference API keys +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_hash TEXT NOT NULL, + name TEXT NOT NULL, + user_id TEXT NOT NULL, + permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance', + expires_at INTEGER NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_used_at INTEGER NULL +); + +-- ----------------------------------------------------------------------------- +-- Key Permissions Table: Per-instance permissions for API keys +-- ----------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS key_permissions ( + key_id INTEGER NOT NULL, + instance_id INTEGER NOT NULL, + can_infer INTEGER NOT NULL DEFAULT 0, + can_view_logs INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (key_id, instance_id), + FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE, + FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE +); + +-- ----------------------------------------------------------------------------- +-- Indexes for API keys and permissions +-- ----------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); +CREATE INDEX IF NOT EXISTS idx_key_permissions_instance_id ON key_permissions(instance_id); diff --git a/pkg/database/permissions.go b/pkg/database/permissions.go new file mode 100644 index 0000000..afd746b --- /dev/null +++ b/pkg/database/permissions.go @@ -0,0 +1,57 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "llamactl/pkg/auth" +) + +// GetPermissions retrieves all permissions for a key +func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) { + query := ` + SELECT key_id, instance_id, can_infer, can_view_logs + FROM key_permissions + WHERE key_id = ? + ORDER BY instance_id + ` + + rows, err := db.QueryContext(ctx, query, keyID) + if err != nil { + return nil, fmt.Errorf("failed to query key permissions: %w", err) + } + defer rows.Close() + + var permissions []auth.KeyPermission + for rows.Next() { + var perm auth.KeyPermission + err := rows.Scan(&perm.KeyID, &perm.InstanceID, &perm.CanInfer, &perm.CanViewLogs) + if err != nil { + return nil, fmt.Errorf("failed to scan key permission: %w", err) + } + permissions = append(permissions, perm) + } + + return permissions, nil +} + +// HasPermission checks if key has inference permission for instance +func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) { + query := ` + SELECT can_infer + FROM key_permissions + WHERE key_id = ? AND instance_id = ? + ` + + var canInfer bool + err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&canInfer) + if err != nil { + if err == sql.ErrNoRows { + // No permission record found, deny access + return false, nil + } + return false, fmt.Errorf("failed to check key permission: %w", err) + } + + return canInfer, nil +} diff --git a/pkg/instance/instance.go b/pkg/instance/instance.go index 376cc0c..465cd5e 100644 --- a/pkg/instance/instance.go +++ b/pkg/instance/instance.go @@ -9,10 +9,11 @@ import ( "time" ) -// Instance represents a running instance of the llama server +// Instance represents a running instance of llama server type Instance struct { + ID int `json:"id"` Name string `json:"name"` - Created int64 `json:"created,omitempty"` // Unix timestamp when the instance was created + Created int64 `json:"created,omitempty"` // Unix timestamp when instance was created // Global configuration globalInstanceSettings *config.InstancesConfig @@ -48,6 +49,7 @@ func New(name string, globalConfig *config.AppConfig, opts *Options, onStatusCha options := newOptions(opts) instance := &Instance{ + ID: 0, // Will be set by database Name: name, options: options, globalInstanceSettings: globalInstanceSettings, @@ -279,11 +281,13 @@ func (i *Instance) buildEnvironment() map[string]string { // MarshalJSON implements json.Marshaler for Instance func (i *Instance) MarshalJSON() ([]byte, error) { return json.Marshal(&struct { + ID int `json:"id"` Name string `json:"name"` Status *status `json:"status"` Created int64 `json:"created,omitempty"` Options *options `json:"options,omitempty"` }{ + ID: i.ID, Name: i.Name, Status: i.status, Created: i.Created, @@ -295,6 +299,7 @@ func (i *Instance) MarshalJSON() ([]byte, error) { func (i *Instance) UnmarshalJSON(data []byte) error { // Explicitly deserialize to match MarshalJSON format aux := &struct { + ID int `json:"id"` Name string `json:"name"` Status *status `json:"status"` Created int64 `json:"created,omitempty"` @@ -306,6 +311,7 @@ func (i *Instance) UnmarshalJSON(data []byte) error { } // Set the fields + i.ID = aux.ID i.Name = aux.Name i.Created = aux.Created i.status = aux.Status diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 5aca037..fc23a2b 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -31,7 +31,7 @@ type instanceManager struct { // Components (each with own synchronization) registry *instanceRegistry ports *portAllocator - db database.DB + db database.InstanceStore remote *remoteManager lifecycle *lifecycleManager @@ -44,7 +44,7 @@ type instanceManager struct { } // New creates a new instance of InstanceManager with dependency injection. -func New(globalConfig *config.AppConfig, db database.DB) InstanceManager { +func New(globalConfig *config.AppConfig, db database.InstanceStore) InstanceManager { if globalConfig.Instances.TimeoutCheckInterval <= 0 { globalConfig.Instances.TimeoutCheckInterval = 5 // Default to 5 minutes if not set diff --git a/pkg/server/handlers.go b/pkg/server/handlers.go index 78b83c5..3e232ee 100644 --- a/pkg/server/handlers.go +++ b/pkg/server/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "llamactl/pkg/config" + "llamactl/pkg/database" "llamactl/pkg/instance" "llamactl/pkg/manager" "llamactl/pkg/validation" @@ -52,20 +53,25 @@ type Handler struct { InstanceManager manager.InstanceManager cfg config.AppConfig httpClient *http.Client + authStore database.AuthStore + authMiddleware *APIAuthMiddleware } // NewHandler creates a new Handler instance with the provided instance manager and configuration -func NewHandler(im manager.InstanceManager, cfg config.AppConfig) *Handler { - return &Handler{ +func NewHandler(im manager.InstanceManager, cfg config.AppConfig, authStore database.AuthStore) *Handler { + handler := &Handler{ InstanceManager: im, cfg: cfg, httpClient: &http.Client{ Timeout: 30 * time.Second, }, + authStore: authStore, } + handler.authMiddleware = NewAPIAuthMiddleware(cfg.Auth, authStore) + return handler } -// getInstance retrieves an instance by name from the request query parameters +// getInstance retrieves an instance by name from request query parameters func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) { name := chi.URLParam(r, "name") validatedName, err := validation.ValidateInstanceName(name) @@ -81,7 +87,7 @@ func (h *Handler) getInstance(r *http.Request) (*instance.Instance, error) { return inst, nil } -// ensureInstanceRunning ensures the instance is running by starting it if on-demand start is enabled +// ensureInstanceRunning ensures that an instance is running by starting it if on-demand start is enabled // It handles LRU eviction when the maximum number of running instances is reached func (h *Handler) ensureInstanceRunning(inst *instance.Instance) error { options := inst.GetOptions() diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go new file mode 100644 index 0000000..3971711 --- /dev/null +++ b/pkg/server/handlers_auth.go @@ -0,0 +1,284 @@ +package server + +import ( + "encoding/json" + "fmt" + "llamactl/pkg/auth" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" +) + +type InstancePermission struct { + InstanceID int `json:"instance_id"` + CanInfer bool `json:"can_infer"` + CanViewLogs bool `json:"can_view_logs"` +} + +type CreateKeyRequest struct { + Name string + PermissionMode auth.PermissionMode + ExpiresAt *int64 + InstancePermissions []InstancePermission +} + +// CreateInferenceKey handles POST /api/v1/keys +func (h *Handler) CreateInferenceKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req CreateKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON in request body") + return + } + + // Validate request + if req.Name == "" { + writeError(w, http.StatusBadRequest, "invalid_name", "Name is required") + return + } + if len(req.Name) > 100 { + writeError(w, http.StatusBadRequest, "invalid_name", "Name must be 100 characters or less") + return + } + if req.PermissionMode != auth.PermissionModeAllowAll && req.PermissionMode != auth.PermissionModePerInstance { + writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'") + return + } + if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstancePermissions) == 0 { + writeError(w, http.StatusBadRequest, "missing_permissions", "Instance permissions required when permission mode is 'per_instance'") + return + } + if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() { + writeError(w, http.StatusBadRequest, "invalid_expires_at", "Expiration time must be in future") + return + } + + // Validate instance IDs exist + if req.PermissionMode == auth.PermissionModePerInstance { + instances, err := h.InstanceManager.ListInstances() + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err)) + return + } + instanceIDMap := make(map[int]bool) + for _, inst := range instances { + instanceIDMap[inst.ID] = true + } + + for _, perm := range req.InstancePermissions { + if !instanceIDMap[perm.InstanceID] { + writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", perm.InstanceID)) + return + } + } + } + + // Generate plain-text key + plainTextKey, err := auth.GenerateKey() + if err != nil { + writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key") + return + } + + // Hash key + keyHash, err := auth.HashKey(plainTextKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "key_hashing_failed", "Failed to hash API key") + return + } + + // Create APIKey struct + now := time.Now().Unix() + apiKey := &auth.APIKey{ + KeyHash: keyHash, + Name: req.Name, + UserID: "system", + PermissionMode: req.PermissionMode, + ExpiresAt: req.ExpiresAt, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } + + // Convert InstancePermissions to KeyPermissions + var keyPermissions []auth.KeyPermission + for _, perm := range req.InstancePermissions { + keyPermissions = append(keyPermissions, auth.KeyPermission{ + KeyID: 0, // Will be set by database after key creation + InstanceID: perm.InstanceID, + CanInfer: perm.CanInfer, + CanViewLogs: perm.CanViewLogs, + }) + } + + // Create in database + err = h.authStore.CreateKey(r.Context(), apiKey, keyPermissions) + if err != nil { + writeError(w, http.StatusInternalServerError, "creation_failed", fmt.Sprintf("Failed to create API key: %v", err)) + return + } // Return response with plain-text key (only shown once) + response := map[string]interface{}{ + "id": apiKey.ID, + "name": apiKey.Name, + "user_id": apiKey.UserID, + "permission_mode": apiKey.PermissionMode, + "expires_at": apiKey.ExpiresAt, + "enabled": apiKey.Enabled, + "created_at": apiKey.CreatedAt, + "updated_at": apiKey.UpdatedAt, + "last_used_at": apiKey.LastUsedAt, + "key": plainTextKey, // Only returned on creation + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) + } +} + +// ListInferenceKeys handles GET /api/v1/keys +func (h *Handler) ListInferenceKeys() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + keys, err := h.authStore.GetUserKeys(r.Context(), "system") + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API keys: %v", err)) + return + } + + // Remove key_hash from all keys + var response []map[string]interface{} + for _, key := range keys { + response = append(response, map[string]interface{}{ + "id": key.ID, + "name": key.Name, + "user_id": key.UserID, + "permission_mode": key.PermissionMode, + "expires_at": key.ExpiresAt, + "enabled": key.Enabled, + "created_at": key.CreatedAt, + "updated_at": key.UpdatedAt, + "last_used_at": key.LastUsedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} + +// GetInferenceKey handles GET /api/v1/keys/{id} +func (h *Handler) GetInferenceKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + key, err := h.authStore.GetKeyByID(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err)) + return + } + + // Remove key_hash from response + response := map[string]interface{}{ + "id": key.ID, + "name": key.Name, + "user_id": key.UserID, + "permission_mode": key.PermissionMode, + "expires_at": key.ExpiresAt, + "enabled": key.Enabled, + "created_at": key.CreatedAt, + "updated_at": key.UpdatedAt, + "last_used_at": key.LastUsedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} + +// DeleteInferenceKey handles DELETE /api/v1/keys/{id} +func (h *Handler) DeleteInferenceKey() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + err = h.authStore.DeleteKey(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "deletion_failed", fmt.Sprintf("Failed to delete API key: %v", err)) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// GetInferenceKeyPermissions handles GET /api/v1/keys/{id}/permissions +func (h *Handler) GetInferenceKeyPermissions() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + id, err := strconv.Atoi(idStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_id", "Invalid key ID") + return + } + + // Verify key exists + _, err = h.authStore.GetKeyByID(r.Context(), id) + if err != nil { + if err.Error() == "API key not found" { + writeError(w, http.StatusNotFound, "not_found", "API key not found") + return + } + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch API key: %v", err)) + return + } + + permissions, err := h.authStore.GetPermissions(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_failed", fmt.Sprintf("Failed to fetch permissions: %v", err)) + return + } + + // Get instance names for the permissions + instances, err := h.InstanceManager.ListInstances() + if err != nil { + writeError(w, http.StatusInternalServerError, "fetch_instances_failed", fmt.Sprintf("Failed to fetch instances: %v", err)) + return + } + instanceNameMap := make(map[int]string) + for _, inst := range instances { + instanceNameMap[inst.ID] = inst.Name + } + + var response []map[string]interface{} + for _, perm := range permissions { + response = append(response, map[string]interface{}{ + "instance_id": perm.InstanceID, + "instance_name": instanceNameMap[perm.InstanceID], + "can_infer": perm.CanInfer, + "can_view_logs": perm.CanViewLogs, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } +} diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 0654be6..0c1797c 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -1,15 +1,19 @@ package server import ( + "context" "crypto/rand" "crypto/subtle" "encoding/hex" "fmt" + "llamactl/pkg/auth" "llamactl/pkg/config" + "llamactl/pkg/database" "log" "net/http" "os" "strings" + "time" ) type KeyType int @@ -19,58 +23,59 @@ const ( KeyTypeManagement ) +// contextKey is a custom type for context keys to avoid collisions +type contextKey string + +const ( + apiKeyContextKey contextKey = "apiKey" +) + type APIAuthMiddleware struct { + authStore database.AuthStore requireInferenceAuth bool - inferenceKeys map[string]bool requireManagementAuth bool - managementKeys map[string]bool + managementKeys map[string]bool // Config-based management keys } // NewAPIAuthMiddleware creates a new APIAuthMiddleware with the given configuration -func NewAPIAuthMiddleware(authCfg config.AuthConfig) *APIAuthMiddleware { +func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStore) *APIAuthMiddleware { + // Load management keys from config into managementKeys map + managementKeys := make(map[string]bool) + for _, key := range authCfg.ManagementKeys { + managementKeys[key] = true + } + // If len(authCfg.InferenceKeys) > 0, log warning + if len(authCfg.InferenceKeys) > 0 { + log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.") + log.Println(" Please create inference keys in web UI or via management API.") + } + + // Handle legacy auto-generation for management keys if none provided and auth is required var generated bool = false - - inferenceAPIKeys := make(map[string]bool) - managementAPIKeys := make(map[string]bool) - const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 { key := generateAPIKey(KeyTypeManagement) - managementAPIKeys[key] = true + managementKeys[key] = true generated = true fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner) fmt.Printf("🔑 Generated Management API Key:\n\n %s\n\n", key) } - for _, key := range authCfg.ManagementKeys { - managementAPIKeys[key] = true - } - - if authCfg.RequireInferenceAuth && len(authCfg.InferenceKeys) == 0 { - key := generateAPIKey(KeyTypeInference) - inferenceAPIKeys[key] = true - generated = true - fmt.Printf("%s\n⚠️ INFERENCE AUTHENTICATION REQUIRED\n%s\n", banner, banner) - fmt.Printf("🔑 Generated Inference API Key:\n\n %s\n\n", key) - } - for _, key := range authCfg.InferenceKeys { - inferenceAPIKeys[key] = true - } if generated { fmt.Printf("%s\n⚠️ IMPORTANT\n%s\n", banner, banner) - fmt.Println("• These keys are auto-generated and will change on restart") + fmt.Println("• This key is auto-generated and will change on restart") fmt.Println("• For production, add explicit keys to your configuration") - fmt.Println("• Copy these keys before they disappear from the terminal") + fmt.Println("• Copy this key before it disappears from the terminal") fmt.Println(banner) } return &APIAuthMiddleware{ + authStore: authStore, requireInferenceAuth: authCfg.RequireInferenceAuth, - inferenceKeys: inferenceAPIKeys, requireManagementAuth: authCfg.RequireManagementAuth, - managementKeys: managementAPIKeys, + managementKeys: managementKeys, } } @@ -100,7 +105,120 @@ func generateAPIKey(keyType KeyType) string { return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(randomBytes)) } -// AuthMiddleware returns a middleware that checks API keys for the given key type +// InferenceAuthMiddleware returns middleware for inference endpoints +func (a *APIAuthMiddleware) InferenceAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + next.ServeHTTP(w, r) + return + } + + // Extract API key from request + apiKey := a.extractAPIKey(r) + if apiKey == "" { + a.unauthorized(w, "Missing API key") + return + } + + // Try database authentication first + var foundKey *auth.APIKey + if a.requireInferenceAuth { + activeKeys, err := a.authStore.GetActiveKeys(r.Context()) + if err != nil { + log.Printf("Failed to get active inference keys: %v", err) + // Continue to management key fallback + } else { + for _, key := range activeKeys { + if auth.VerifyKey(apiKey, key.KeyHash) { + foundKey = key + // Async update last_used_at + go func(keyID int) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.authStore.TouchKey(ctx, keyID); err != nil { + log.Printf("Failed to update last used timestamp for key %d: %v", keyID, err) + } + }(key.ID) + break + } + } + } + } + + // If no database key found, try management key authentication (config-based) + if foundKey == nil { + if !a.isValidManagementKey(apiKey) { + a.unauthorized(w, "Invalid API key") + return + } + // Management key was used, continue without adding APIKey to context + } else { + // Add APIKey to context for permission checking + ctx := context.WithValue(r.Context(), apiKeyContextKey, foundKey) + r = r.WithContext(ctx) + } + + next.ServeHTTP(w, r) + }) + } +} + +// ManagementAuthMiddleware returns middleware for management endpoints +func (a *APIAuthMiddleware) ManagementAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + next.ServeHTTP(w, r) + return + } + + // Extract API key from request + apiKey := a.extractAPIKey(r) + if apiKey == "" { + a.unauthorized(w, "Missing API key") + return + } + + // Check if key exists in managementKeys map using constant-time comparison + if !a.isValidManagementKey(apiKey) { + a.unauthorized(w, "Invalid API key") + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// CheckInstancePermission checks if the authenticated key has permission for the instance +func (a *APIAuthMiddleware) CheckInstancePermission(ctx context.Context, instanceID int) error { + // Extract APIKey from context + apiKey, ok := ctx.Value(apiKeyContextKey).(*auth.APIKey) + if !ok { + // APIKey is nil, management key was used, allow all + return nil + } + + // If permission_mode == "allow_all", allow all + if apiKey.PermissionMode == auth.PermissionModeAllowAll { + return nil + } + + // Check per-instance permissions + canInfer, err := a.authStore.HasPermission(ctx, apiKey.ID, instanceID) + if err != nil { + return err + } + + if !canInfer { + return http.ErrBodyNotAllowed // Use this as a generic error to indicate permission denied + } + + return nil +} + +// AuthMiddleware returns a middleware that checks API keys for the given key type (legacy support) func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -118,10 +236,38 @@ func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) h var isValid bool switch keyType { case KeyTypeInference: - // Management keys also work for OpenAI endpoints (higher privilege) - isValid = a.isValidKey(apiKey, KeyTypeInference) || a.isValidKey(apiKey, KeyTypeManagement) + // Try database authentication first + if a.requireInferenceAuth { + activeKeys, err := a.authStore.GetActiveKeys(r.Context()) + if err == nil { + for _, key := range activeKeys { + if auth.VerifyKey(apiKey, key.KeyHash) { + foundKey := key + // Async update last_used_at + go func(keyID int) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.authStore.TouchKey(ctx, keyID); err != nil { + log.Printf("Failed to update last used timestamp for key %d: %v", keyID, err) + } + }(key.ID) + + // Add APIKey to context for permission checking + ctx := context.WithValue(r.Context(), apiKeyContextKey, foundKey) + r = r.WithContext(ctx) + isValid = true + break + } + } + } + } + + // If no database key found, try management key (higher privilege) + if !isValid { + isValid = a.isValidManagementKey(apiKey) + } case KeyTypeManagement: - isValid = a.isValidKey(apiKey, KeyTypeManagement) + isValid = a.isValidManagementKey(apiKey) default: isValid = false } @@ -158,20 +304,9 @@ func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string { return "" } -// isValidKey checks if the provided API key is valid for the given key type -func (a *APIAuthMiddleware) isValidKey(providedKey string, keyType KeyType) bool { - var validKeys map[string]bool - - switch keyType { - case KeyTypeInference: - validKeys = a.inferenceKeys - case KeyTypeManagement: - validKeys = a.managementKeys - default: - return false - } - - for validKey := range validKeys { +// isValidManagementKey checks if the provided API key is a valid management key +func (a *APIAuthMiddleware) isValidManagementKey(providedKey string) bool { + for validKey := range a.managementKeys { if len(providedKey) == len(validKey) && subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 { return true @@ -187,3 +322,11 @@ func (a *APIAuthMiddleware) unauthorized(w http.ResponseWriter, message string) response := fmt.Sprintf(`{"error": {"message": "%s", "type": "authentication_error"}}`, message) w.Write([]byte(response)) } + +// forbidden sends a forbidden response +func (a *APIAuthMiddleware) forbidden(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + response := fmt.Sprintf(`{"error": {"message": "%s", "type": "permission_denied"}}`, message) + w.Write([]byte(response)) +} diff --git a/pkg/server/middleware_test.go b/pkg/server/middleware_test.go index 8a1e7fc..720362f 100644 --- a/pkg/server/middleware_test.go +++ b/pkg/server/middleware_test.go @@ -19,15 +19,7 @@ func TestAuthMiddleware(t *testing.T) { method string expectedStatus int }{ - // Valid key tests - { - name: "valid inference key for inference", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "sk-inference-valid123", - method: "GET", - expectedStatus: http.StatusOK, - }, + // Valid key tests - using management keys only since config-based inference keys are deprecated { name: "valid management key for inference", // Management keys work for inference keyType: server.KeyTypeInference, @@ -123,7 +115,7 @@ func TestAuthMiddleware(t *testing.T) { InferenceKeys: tt.inferenceKeys, ManagementKeys: tt.managementKeys, } - middleware := server.NewAPIAuthMiddleware(cfg) + middleware := server.NewAPIAuthMiddleware(cfg, nil) // Create test request req := httptest.NewRequest(tt.method, "/test", nil) @@ -131,7 +123,7 @@ func TestAuthMiddleware(t *testing.T) { req.Header.Set("Authorization", "Bearer "+tt.requestKey) } - // Create test handler using the appropriate middleware + // Create test handler using appropriate middleware var handler http.Handler if tt.keyType == server.KeyTypeInference { handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -189,7 +181,7 @@ func TestGenerateAPIKey(t *testing.T) { } // Create middleware - this should trigger key generation - middleware := server.NewAPIAuthMiddleware(config) + middleware := server.NewAPIAuthMiddleware(config, nil) // Test that auth is required (meaning a key was generated) req := httptest.NewRequest("GET", "/", nil) @@ -214,7 +206,7 @@ func TestGenerateAPIKey(t *testing.T) { } // Test uniqueness by creating another middleware instance - middleware2 := server.NewAPIAuthMiddleware(config) + middleware2 := server.NewAPIAuthMiddleware(config, nil) req2 := httptest.NewRequest("GET", "/", nil) recorder2 := httptest.NewRecorder() @@ -314,7 +306,7 @@ func TestAutoGeneration(t *testing.T) { ManagementKeys: tt.providedManagement, } - middleware := server.NewAPIAuthMiddleware(cfg) + middleware := server.NewAPIAuthMiddleware(cfg, nil) // Test inference behavior if inference auth is required if tt.requireInference { diff --git a/pkg/server/routes.go b/pkg/server/routes.go index b159968..36a6081 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -27,7 +27,7 @@ func SetupRouter(handler *Handler) *chi.Mux { })) // Add API authentication middleware - authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth) + authMiddleware := NewAPIAuthMiddleware(handler.cfg.Auth, handler.authStore) if handler.cfg.Server.EnableSwagger { r.Get("/swagger/*", httpSwagger.Handler( @@ -46,6 +46,17 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Get("/config", handler.ConfigHandler()) + // API key management endpoints + r.Route("/auth", func(r chi.Router) { + r.Route("/keys", func(r chi.Router) { + r.Post("/", handler.CreateInferenceKey()) // Create API key + r.Get("/", handler.ListInferenceKeys()) // List API keys + r.Get("/{id}", handler.GetInferenceKey()) // Get API key details + r.Delete("/{id}", handler.DeleteInferenceKey()) // Delete API key + r.Get("/{id}/permissions", handler.GetInferenceKeyPermissions()) // Get key permissions + }) + }) + // Backend-specific endpoints r.Route("/backends", func(r chi.Router) { r.Route("/llama-cpp", func(r chi.Router) { @@ -94,13 +105,13 @@ func SetupRouter(handler *Handler) *chi.Mux { }) }) - r.Route(("/v1"), func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth { r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) } - r.Get(("/models"), handler.OpenAIListInstances()) // List instances in OpenAI-compatible format + r.Get("/models", handler.OpenAIListInstances()) // List instances in OpenAI-compatible format // OpenAI-compatible proxy endpoint // Handles all POST requests to /v1/*, including: @@ -128,7 +139,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) } - // This handler auto start the server if it's not running + // This handler auto starts the server if it's not running llamaCppHandler := handler.LlamaCppProxy() // llama.cpp server specific proxy endpoints From 5ccf493e04cea3d9bfc06ba664e4b3f967ad996a Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 3 Dec 2025 21:14:44 +0100 Subject: [PATCH 02/24] Add permission checks to proxies --- pkg/auth/key.go | 8 +- pkg/server/handlers_auth.go | 114 +++++---- pkg/server/handlers_backends.go | 6 + pkg/server/handlers_instances.go | 6 + pkg/server/handlers_openai.go | 6 + pkg/server/middleware.go | 112 +-------- pkg/server/middleware_test.go | 384 +++++++++++++------------------ pkg/server/routes.go | 6 +- 8 files changed, 271 insertions(+), 371 deletions(-) diff --git a/pkg/auth/key.go b/pkg/auth/key.go index 211647c..9485c1b 100644 --- a/pkg/auth/key.go +++ b/pkg/auth/key.go @@ -33,9 +33,8 @@ type KeyPermission struct { CanViewLogs bool } -// GenerateKey generates a cryptographically secure inference API key -// Format: sk-inference-<64-hex-chars> -func GenerateKey() (string, error) { +// GenerateKey generates a cryptographically secure API key with the given prefix +func GenerateKey(prefix string) (string, error) { // Generate 32 random bytes bytes := make([]byte, 32) _, err := rand.Read(bytes) @@ -46,6 +45,5 @@ func GenerateKey() (string, error) { // Convert to hex (64 characters) hexStr := hex.EncodeToString(bytes) - // Prefix with "sk-inference-" - return fmt.Sprintf("sk-inference-%s", hexStr), nil + return fmt.Sprintf("%s-%s", prefix, hexStr), nil } diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index 3971711..2be79b0 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -24,6 +24,38 @@ type CreateKeyRequest struct { InstancePermissions []InstancePermission } +type CreateKeyResponse struct { + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` + Key string `json:"key"` +} + +type KeyResponse struct { + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` +} + +type KeyPermissionResponse struct { + InstanceID int `json:"instance_id"` + InstanceName string `json:"instance_name"` + CanInfer bool `json:"can_infer"` + CanViewLogs bool `json:"can_view_logs"` +} + // CreateInferenceKey handles POST /api/v1/keys func (h *Handler) CreateInferenceKey() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -76,7 +108,7 @@ func (h *Handler) CreateInferenceKey() http.HandlerFunc { } // Generate plain-text key - plainTextKey, err := auth.GenerateKey() + plainTextKey, err := auth.GenerateKey("llamactl-") if err != nil { writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key") return @@ -118,18 +150,20 @@ func (h *Handler) CreateInferenceKey() http.HandlerFunc { if err != nil { writeError(w, http.StatusInternalServerError, "creation_failed", fmt.Sprintf("Failed to create API key: %v", err)) return - } // Return response with plain-text key (only shown once) - response := map[string]interface{}{ - "id": apiKey.ID, - "name": apiKey.Name, - "user_id": apiKey.UserID, - "permission_mode": apiKey.PermissionMode, - "expires_at": apiKey.ExpiresAt, - "enabled": apiKey.Enabled, - "created_at": apiKey.CreatedAt, - "updated_at": apiKey.UpdatedAt, - "last_used_at": apiKey.LastUsedAt, - "key": plainTextKey, // Only returned on creation + } + + // Return response with plain-text key (only shown once) + response := CreateKeyResponse{ + ID: apiKey.ID, + Name: apiKey.Name, + UserID: apiKey.UserID, + PermissionMode: apiKey.PermissionMode, + ExpiresAt: apiKey.ExpiresAt, + Enabled: apiKey.Enabled, + CreatedAt: apiKey.CreatedAt, + UpdatedAt: apiKey.UpdatedAt, + LastUsedAt: apiKey.LastUsedAt, + Key: plainTextKey, } w.Header().Set("Content-Type", "application/json") @@ -148,18 +182,18 @@ func (h *Handler) ListInferenceKeys() http.HandlerFunc { } // Remove key_hash from all keys - var response []map[string]interface{} + response := make([]KeyResponse, 0, len(keys)) for _, key := range keys { - response = append(response, map[string]interface{}{ - "id": key.ID, - "name": key.Name, - "user_id": key.UserID, - "permission_mode": key.PermissionMode, - "expires_at": key.ExpiresAt, - "enabled": key.Enabled, - "created_at": key.CreatedAt, - "updated_at": key.UpdatedAt, - "last_used_at": key.LastUsedAt, + response = append(response, KeyResponse{ + ID: key.ID, + Name: key.Name, + UserID: key.UserID, + PermissionMode: key.PermissionMode, + ExpiresAt: key.ExpiresAt, + Enabled: key.Enabled, + CreatedAt: key.CreatedAt, + UpdatedAt: key.UpdatedAt, + LastUsedAt: key.LastUsedAt, }) } @@ -189,16 +223,16 @@ func (h *Handler) GetInferenceKey() http.HandlerFunc { } // Remove key_hash from response - response := map[string]interface{}{ - "id": key.ID, - "name": key.Name, - "user_id": key.UserID, - "permission_mode": key.PermissionMode, - "expires_at": key.ExpiresAt, - "enabled": key.Enabled, - "created_at": key.CreatedAt, - "updated_at": key.UpdatedAt, - "last_used_at": key.LastUsedAt, + response := KeyResponse{ + ID: key.ID, + Name: key.Name, + UserID: key.UserID, + PermissionMode: key.PermissionMode, + ExpiresAt: key.ExpiresAt, + Enabled: key.Enabled, + CreatedAt: key.CreatedAt, + UpdatedAt: key.UpdatedAt, + LastUsedAt: key.LastUsedAt, } w.Header().Set("Content-Type", "application/json") @@ -268,13 +302,13 @@ func (h *Handler) GetInferenceKeyPermissions() http.HandlerFunc { instanceNameMap[inst.ID] = inst.Name } - var response []map[string]interface{} + response := make([]KeyPermissionResponse, 0, len(permissions)) for _, perm := range permissions { - response = append(response, map[string]interface{}{ - "instance_id": perm.InstanceID, - "instance_name": instanceNameMap[perm.InstanceID], - "can_infer": perm.CanInfer, - "can_view_logs": perm.CanViewLogs, + response = append(response, KeyPermissionResponse{ + InstanceID: perm.InstanceID, + InstanceName: instanceNameMap[perm.InstanceID], + CanInfer: perm.CanInfer, + CanViewLogs: perm.CanViewLogs, }) } diff --git a/pkg/server/handlers_backends.go b/pkg/server/handlers_backends.go index 1e249f9..065b24e 100644 --- a/pkg/server/handlers_backends.go +++ b/pkg/server/handlers_backends.go @@ -109,6 +109,12 @@ func (h *Handler) LlamaCppProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + // Check if instance is shutting down before autostart logic if inst.GetStatus() == instance.ShuttingDown { writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down") diff --git a/pkg/server/handlers_instances.go b/pkg/server/handlers_instances.go index 43bed3e..e155fd1 100644 --- a/pkg/server/handlers_instances.go +++ b/pkg/server/handlers_instances.go @@ -327,6 +327,12 @@ func (h *Handler) InstanceProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + if !inst.IsRunning() { writeError(w, http.StatusServiceUnavailable, "instance_not_running", "Instance is not running") return diff --git a/pkg/server/handlers_openai.go b/pkg/server/handlers_openai.go index 81aa9e7..a7ad635 100644 --- a/pkg/server/handlers_openai.go +++ b/pkg/server/handlers_openai.go @@ -107,6 +107,12 @@ func (h *Handler) OpenAIProxy() http.HandlerFunc { return } + // Check instance permissions + if err := h.authMiddleware.CheckInstancePermission(r.Context(), inst.ID); err != nil { + writeError(w, http.StatusForbidden, "permission_denied", err.Error()) + return + } + // Check if instance is shutting down before autostart logic if inst.GetStatus() == instance.ShuttingDown { writeError(w, http.StatusServiceUnavailable, "instance_shutting_down", "Instance is shutting down") diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 0c1797c..cd4f24f 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -2,9 +2,7 @@ package server import ( "context" - "crypto/rand" "crypto/subtle" - "encoding/hex" "fmt" "llamactl/pkg/auth" "llamactl/pkg/config" @@ -16,13 +14,6 @@ import ( "time" ) -type KeyType int - -const ( - KeyTypeInference KeyType = iota - KeyTypeManagement -) - // contextKey is a custom type for context keys to avoid collisions type contextKey string @@ -56,7 +47,12 @@ func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStor const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 { - key := generateAPIKey(KeyTypeManagement) + key, err := auth.GenerateKey("llamactl-mgmt-") + if err != nil { + log.Printf("Warning: Failed to generate management key: %v", err) + // Fallback to PID-based key for safety + key = fmt.Sprintf("sk-management-fallback-%d", os.Getpid()) + } managementKeys[key] = true generated = true fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner) @@ -79,32 +75,6 @@ func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStor } } -// generateAPIKey creates a cryptographically secure API key -func generateAPIKey(keyType KeyType) string { - // Generate 32 random bytes (256 bits) - randomBytes := make([]byte, 32) - - var prefix string - - switch keyType { - case KeyTypeInference: - prefix = "sk-inference" - case KeyTypeManagement: - prefix = "sk-management" - default: - prefix = "sk-unknown" - } - - if _, err := rand.Read(randomBytes); err != nil { - log.Printf("Warning: Failed to generate secure random key, using fallback") - // Fallback to a less secure method if crypto/rand fails - return fmt.Sprintf("%s-fallback-%d", prefix, os.Getpid()) - } - - // Convert to hex and add prefix - return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(randomBytes)) -} - // InferenceAuthMiddleware returns middleware for inference endpoints func (a *APIAuthMiddleware) InferenceAuthMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -123,7 +93,7 @@ func (a *APIAuthMiddleware) InferenceAuthMiddleware() func(http.Handler) http.Ha // Try database authentication first var foundKey *auth.APIKey - if a.requireInferenceAuth { + if a.requireInferenceAuth && a.authStore != nil { activeKeys, err := a.authStore.GetActiveKeys(r.Context()) if err != nil { log.Printf("Failed to get active inference keys: %v", err) @@ -208,80 +178,16 @@ func (a *APIAuthMiddleware) CheckInstancePermission(ctx context.Context, instanc // Check per-instance permissions canInfer, err := a.authStore.HasPermission(ctx, apiKey.ID, instanceID) if err != nil { - return err + return fmt.Errorf("failed to check permission: %w", err) } if !canInfer { - return http.ErrBodyNotAllowed // Use this as a generic error to indicate permission denied + return fmt.Errorf("permission denied: key does not have access to this instance") } return nil } -// AuthMiddleware returns a middleware that checks API keys for the given key type (legacy support) -func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "OPTIONS" { - next.ServeHTTP(w, r) - return - } - - apiKey := a.extractAPIKey(r) - if apiKey == "" { - a.unauthorized(w, "Missing API key") - return - } - - var isValid bool - switch keyType { - case KeyTypeInference: - // Try database authentication first - if a.requireInferenceAuth { - activeKeys, err := a.authStore.GetActiveKeys(r.Context()) - if err == nil { - for _, key := range activeKeys { - if auth.VerifyKey(apiKey, key.KeyHash) { - foundKey := key - // Async update last_used_at - go func(keyID int) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := a.authStore.TouchKey(ctx, keyID); err != nil { - log.Printf("Failed to update last used timestamp for key %d: %v", keyID, err) - } - }(key.ID) - - // Add APIKey to context for permission checking - ctx := context.WithValue(r.Context(), apiKeyContextKey, foundKey) - r = r.WithContext(ctx) - isValid = true - break - } - } - } - } - - // If no database key found, try management key (higher privilege) - if !isValid { - isValid = a.isValidManagementKey(apiKey) - } - case KeyTypeManagement: - isValid = a.isValidManagementKey(apiKey) - default: - isValid = false - } - - if !isValid { - a.unauthorized(w, "Invalid API key") - return - } - - next.ServeHTTP(w, r) - }) - } -} - // extractAPIKey extracts the API key from the request func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string { // Check Authorization header: "Bearer sk-..." diff --git a/pkg/server/middleware_test.go b/pkg/server/middleware_test.go index 720362f..6a552e4 100644 --- a/pkg/server/middleware_test.go +++ b/pkg/server/middleware_test.go @@ -9,99 +9,44 @@ import ( "testing" ) -func TestAuthMiddleware(t *testing.T) { +func TestInferenceAuthMiddleware(t *testing.T) { tests := []struct { name string - keyType server.KeyType - inferenceKeys []string managementKeys []string requestKey string method string expectedStatus int }{ - // Valid key tests - using management keys only since config-based inference keys are deprecated { - name: "valid management key for inference", // Management keys work for inference - keyType: server.KeyTypeInference, + name: "valid management key for inference", managementKeys: []string{"sk-management-admin123"}, requestKey: "sk-management-admin123", method: "GET", expectedStatus: http.StatusOK, }, { - name: "valid management key for management", - keyType: server.KeyTypeManagement, - managementKeys: []string{"sk-management-admin123"}, - requestKey: "sk-management-admin123", - method: "GET", - expectedStatus: http.StatusOK, - }, - - // Invalid key tests - { - name: "inference key for management should fail", - keyType: server.KeyTypeManagement, - inferenceKeys: []string{"sk-inference-user123"}, - requestKey: "sk-inference-user123", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "invalid inference key", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "sk-inference-invalid", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "missing inference key", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "", - method: "GET", - expectedStatus: http.StatusUnauthorized, - }, - { - name: "invalid management key", - keyType: server.KeyTypeManagement, + name: "invalid key", managementKeys: []string{"sk-management-valid123"}, requestKey: "sk-management-invalid", method: "GET", expectedStatus: http.StatusUnauthorized, }, { - name: "missing management key", - keyType: server.KeyTypeManagement, + name: "missing key", managementKeys: []string{"sk-management-valid123"}, requestKey: "", method: "GET", expectedStatus: http.StatusUnauthorized, }, - - // OPTIONS requests should always pass { - name: "OPTIONS request bypasses inference auth", - keyType: server.KeyTypeInference, - inferenceKeys: []string{"sk-inference-valid123"}, - requestKey: "", - method: "OPTIONS", - expectedStatus: http.StatusOK, - }, - { - name: "OPTIONS request bypasses management auth", - keyType: server.KeyTypeManagement, + name: "OPTIONS request bypasses auth", managementKeys: []string{"sk-management-valid123"}, requestKey: "", method: "OPTIONS", expectedStatus: http.StatusOK, }, - - // Cross-key-type validation { name: "management key works for inference endpoint", - keyType: server.KeyTypeInference, - inferenceKeys: []string{}, managementKeys: []string{"sk-management-admin"}, requestKey: "sk-management-admin", method: "POST", @@ -112,8 +57,8 @@ func TestAuthMiddleware(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := config.AuthConfig{ - InferenceKeys: tt.inferenceKeys, - ManagementKeys: tt.managementKeys, + RequireInferenceAuth: true, + ManagementKeys: tt.managementKeys, } middleware := server.NewAPIAuthMiddleware(cfg, nil) @@ -123,24 +68,17 @@ func TestAuthMiddleware(t *testing.T) { req.Header.Set("Authorization", "Bearer "+tt.requestKey) } - // Create test handler using appropriate middleware - var handler http.Handler - if tt.keyType == server.KeyTypeInference { - handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } else { - handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } + // Create test handler + handler := middleware.InferenceAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) // Execute request recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) if recorder.Code != tt.expectedStatus { - t.Errorf("AuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) + t.Errorf("InferenceAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) } // Check that unauthorized responses have proper format @@ -159,178 +97,171 @@ func TestAuthMiddleware(t *testing.T) { } } -func TestGenerateAPIKey(t *testing.T) { +func TestManagementAuthMiddleware(t *testing.T) { tests := []struct { - name string - keyType server.KeyType - }{ - {"inference key generation", server.KeyTypeInference}, - {"management key generation", server.KeyTypeManagement}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Test auto-generation by creating config that will trigger it - var config config.AuthConfig - if tt.keyType == server.KeyTypeInference { - config.RequireInferenceAuth = true - config.InferenceKeys = []string{} // Empty to trigger generation - } else { - config.RequireManagementAuth = true - config.ManagementKeys = []string{} // Empty to trigger generation - } - - // Create middleware - this should trigger key generation - middleware := server.NewAPIAuthMiddleware(config, nil) - - // Test that auth is required (meaning a key was generated) - req := httptest.NewRequest("GET", "/", nil) - recorder := httptest.NewRecorder() - - var handler http.Handler - if tt.keyType == server.KeyTypeInference { - handler = middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } else { - handler = middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - } - - handler.ServeHTTP(recorder, req) - - // Should be unauthorized without a key (proving that a key was generated and auth is working) - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized without key, got status %v", recorder.Code) - } - - // Test uniqueness by creating another middleware instance - middleware2 := server.NewAPIAuthMiddleware(config, nil) - - req2 := httptest.NewRequest("GET", "/", nil) - recorder2 := httptest.NewRecorder() - - if tt.keyType == server.KeyTypeInference { - handler2 := middleware2.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - handler2.ServeHTTP(recorder2, req2) - } else { - handler2 := middleware2.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - handler2.ServeHTTP(recorder2, req2) - } - - // Both should require auth (proving keys were generated for both instances) - if recorder2.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code) - } - }) - } -} - -func TestAutoGeneration(t *testing.T) { - tests := []struct { - name string - requireInference bool - requireManagement bool - providedInference []string - providedManagement []string - shouldGenerateInf bool // Whether inference key should be generated - shouldGenerateMgmt bool // Whether management key should be generated + name string + managementKeys []string + requestKey string + method string + expectedStatus int }{ { - name: "inference auth required, keys provided - no generation", - requireInference: true, - requireManagement: false, - providedInference: []string{"sk-inference-provided"}, - providedManagement: []string{}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, + name: "valid management key", + managementKeys: []string{"sk-management-admin123"}, + requestKey: "sk-management-admin123", + method: "GET", + expectedStatus: http.StatusOK, }, { - name: "inference auth required, no keys - should auto-generate", - requireInference: true, - requireManagement: false, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: true, - shouldGenerateMgmt: false, + name: "invalid management key", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "sk-management-invalid", + method: "GET", + expectedStatus: http.StatusUnauthorized, }, { - name: "management auth required, keys provided - no generation", - requireInference: false, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{"sk-management-provided"}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, + name: "missing management key", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "", + method: "GET", + expectedStatus: http.StatusUnauthorized, }, { - name: "management auth required, no keys - should auto-generate", - requireInference: false, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: false, - shouldGenerateMgmt: true, - }, - { - name: "both required, both provided - no generation", - requireInference: true, - requireManagement: true, - providedInference: []string{"sk-inference-provided"}, - providedManagement: []string{"sk-management-provided"}, - shouldGenerateInf: false, - shouldGenerateMgmt: false, - }, - { - name: "both required, none provided - should auto-generate both", - requireInference: true, - requireManagement: true, - providedInference: []string{}, - providedManagement: []string{}, - shouldGenerateInf: true, - shouldGenerateMgmt: true, + name: "OPTIONS request bypasses management auth", + managementKeys: []string{"sk-management-valid123"}, + requestKey: "", + method: "OPTIONS", + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.AuthConfig{ + RequireManagementAuth: true, + ManagementKeys: tt.managementKeys, + } + middleware := server.NewAPIAuthMiddleware(cfg, nil) + + // Create test request + req := httptest.NewRequest(tt.method, "/test", nil) + if tt.requestKey != "" { + req.Header.Set("Authorization", "Bearer "+tt.requestKey) + } + + // Create test handler + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Execute request + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + if recorder.Code != tt.expectedStatus { + t.Errorf("ManagementAuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus) + } + + // Check that unauthorized responses have proper format + if recorder.Code == http.StatusUnauthorized { + contentType := recorder.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Unauthorized response Content-Type = %v, expected application/json", contentType) + } + + body := recorder.Body.String() + if !strings.Contains(body, `"type": "authentication_error"`) { + t.Errorf("Unauthorized response missing proper error type: %v", body) + } + } + }) + } +} + +func TestManagementKeyAutoGeneration(t *testing.T) { + // Test auto-generation for management keys + config := config.AuthConfig{ + RequireManagementAuth: true, + ManagementKeys: []string{}, // Empty to trigger generation + } + + // Create middleware - this should trigger key generation + middleware := server.NewAPIAuthMiddleware(config, nil) + + // Test that auth is required (meaning a key was generated) + req := httptest.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(recorder, req) + + // Should be unauthorized without a key (proving that a key was generated and auth is working) + if recorder.Code != http.StatusUnauthorized { + t.Errorf("Expected unauthorized without key, got status %v", recorder.Code) + } + + // Test uniqueness by creating another middleware instance + middleware2 := server.NewAPIAuthMiddleware(config, nil) + + req2 := httptest.NewRequest("GET", "/", nil) + recorder2 := httptest.NewRecorder() + + handler2 := middleware2.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler2.ServeHTTP(recorder2, req2) + + // Both should require auth (proving keys were generated for both instances) + if recorder2.Code != http.StatusUnauthorized { + t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code) + } +} + +func TestAutoGenerationScenarios(t *testing.T) { + tests := []struct { + name string + requireManagement bool + providedManagement []string + shouldGenerate bool + }{ + { + name: "management auth required, keys provided - no generation", + requireManagement: true, + providedManagement: []string{"sk-management-provided"}, + shouldGenerate: false, + }, + { + name: "management auth required, no keys - should auto-generate", + requireManagement: true, + providedManagement: []string{}, + shouldGenerate: true, + }, + { + name: "management auth not required - no generation", + requireManagement: false, + providedManagement: []string{}, + shouldGenerate: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := config.AuthConfig{ - RequireInferenceAuth: tt.requireInference, RequireManagementAuth: tt.requireManagement, - InferenceKeys: tt.providedInference, ManagementKeys: tt.providedManagement, } middleware := server.NewAPIAuthMiddleware(cfg, nil) - // Test inference behavior if inference auth is required - if tt.requireInference { - req := httptest.NewRequest("GET", "/v1/models", nil) - recorder := httptest.NewRecorder() - - handler := middleware.AuthMiddleware(server.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - handler.ServeHTTP(recorder, req) - - // Should always be unauthorized without a key (since middleware assumes auth is required) - if recorder.Code != http.StatusUnauthorized { - t.Errorf("Expected unauthorized for inference without key, got status %v", recorder.Code) - } - } - // Test management behavior if management auth is required if tt.requireManagement { req := httptest.NewRequest("GET", "/api/v1/instances", nil) recorder := httptest.NewRecorder() - handler := middleware.AuthMiddleware(server.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := middleware.ManagementAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -344,3 +275,16 @@ func TestAutoGeneration(t *testing.T) { }) } } + +func TestConfigBasedInferenceKeysDeprecationWarning(t *testing.T) { + // Test that config-based inference keys trigger a warning (captured in logs) + cfg := config.AuthConfig{ + InferenceKeys: []string{"sk-inference-old"}, + } + + // Creating middleware should log a warning, but shouldn't fail + _ = server.NewAPIAuthMiddleware(cfg, nil) + + // If we get here without panic, the test passes + // The warning is logged but not returned as an error +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 36a6081..6920a61 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -39,7 +39,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Route("/api/v1", func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireManagementAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement)) + r.Use(authMiddleware.ManagementAuthMiddleware()) } r.Get("/version", handler.VersionHandler()) @@ -108,7 +108,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Route("/v1", func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) + r.Use(authMiddleware.InferenceAuthMiddleware()) } r.Get("/models", handler.OpenAIListInstances()) // List instances in OpenAI-compatible format @@ -136,7 +136,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Group(func(r chi.Router) { if authMiddleware != nil && handler.cfg.Auth.RequireInferenceAuth { - r.Use(authMiddleware.AuthMiddleware(KeyTypeInference)) + r.Use(authMiddleware.InferenceAuthMiddleware()) } // This handler auto starts the server if it's not running From 85cf712b03e86f08b030a9b194a40c93dde161ec Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 3 Dec 2025 21:25:03 +0100 Subject: [PATCH 03/24] Update api docs --- docs/docs.go | 399 +++++++++++++++++++++++++++++++++++- docs/swagger.json | 399 +++++++++++++++++++++++++++++++++++- docs/swagger.yaml | 269 +++++++++++++++++++++++- pkg/config/config.go | 2 +- pkg/server/handlers_auth.go | 113 +++++++--- pkg/server/routes.go | 10 +- 6 files changed, 1136 insertions(+), 56 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 8d6a8f1..4a9bce6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19,6 +19,235 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/auth/keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "List all API keys", + "responses": { + "200": { + "description": "List of API keys", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyResponse" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Create a new API key", + "parameters": [ + { + "description": "API key configuration", + "name": "key", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created API key with plain-text key", + "schema": { + "$ref": "#/definitions/server.CreateKeyResponse" + } + }, + "400": { + "description": "Invalid request body or validation error", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get details of a specific API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "API key details", + "schema": { + "$ref": "#/definitions/server.KeyResponse" + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an API key by ID", + "tags": [ + "Keys" + ], + "summary": "Delete an API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "API key deleted successfully" + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}/permissions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the instance-level permissions for a specific API key (includes instance names)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get API key permissions", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of key permissions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyPermissionResponse" + } + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/backends/llama-cpp/devices": { "get": { "security": [ @@ -1503,6 +1732,17 @@ const docTemplate = `{ } }, "definitions": { + "auth.PermissionMode": { + "type": "string", + "enum": [ + "allow_all", + "per_instance" + ], + "x-enum-varnames": [ + "PermissionModeAllowAll", + "PermissionModePerInstance" + ] + }, "config.AppConfig": { "type": "object", "properties": { @@ -1518,6 +1758,13 @@ const docTemplate = `{ "commit_hash": { "type": "string" }, + "data_dir": { + "description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)", + "type": "string" + }, + "database": { + "$ref": "#/definitions/config.DatabaseConfig" + }, "instances": { "$ref": "#/definitions/config.InstancesConfig" }, @@ -1608,6 +1855,26 @@ const docTemplate = `{ } } }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "connection_max_lifetime": { + "type": "string", + "example": "1h" + }, + "max_idle_connections": { + "type": "integer" + }, + "max_open_connections": { + "description": "Connection settings", + "type": "integer" + }, + "path": { + "description": "Database file path (relative to the top-level data_dir or absolute)", + "type": "string" + } + } + }, "config.DockerSettings": { "type": "object", "properties": { @@ -1639,11 +1906,7 @@ const docTemplate = `{ "type": "boolean" }, "configs_dir": { - "description": "Instance config directory override", - "type": "string" - }, - "data_dir": { - "description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)", + "description": "Instance config directory override (relative to data_dir if not absolute)", "type": "string" }, "default_auto_restart": { @@ -1667,7 +1930,7 @@ const docTemplate = `{ "type": "boolean" }, "logs_dir": { - "description": "Logs directory override", + "description": "Logs directory override (relative to data_dir if not absolute)", "type": "string" }, "max_instances": { @@ -1748,7 +2011,10 @@ const docTemplate = `{ "type": "object", "properties": { "created": { - "description": "Unix timestamp when the instance was created", + "description": "Unix timestamp when instance was created", + "type": "integer" + }, + "id": { "type": "integer" }, "name": { @@ -1794,6 +2060,125 @@ const docTemplate = `{ } } }, + "server.CreateKeyRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "integer", + "format": "int64" + }, + "instancePermissions": { + "type": "array", + "items": { + "$ref": "#/definitions/server.InstancePermission" + } + }, + "name": { + "type": "string" + }, + "permissionMode": { + "$ref": "#/definitions/auth.PermissionMode" + } + } + }, + "server.CreateKeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, + "server.InstancePermission": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + } + } + }, + "server.KeyPermissionResponse": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + }, + "instance_name": { + "type": "string" + } + } + }, + "server.KeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, "server.NodeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index f79a008..25cf87d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -12,6 +12,235 @@ }, "basePath": "/api/v1", "paths": { + "/api/v1/auth/keys": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all API keys for the system user (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "List all API keys", + "responses": { + "200": { + "description": "List of API keys", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyResponse" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "description": "Creates a new API key with the specified permissions and returns the plain-text key (only shown once)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Create a new API key", + "parameters": [ + { + "description": "API key configuration", + "name": "key", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created API key with plain-text key", + "schema": { + "$ref": "#/definitions/server.CreateKeyResponse" + } + }, + "400": { + "description": "Invalid request body or validation error", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns details for a specific API key by ID (excludes key hash and plain-text key)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get details of a specific API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "API key details", + "schema": { + "$ref": "#/definitions/server.KeyResponse" + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an API key by ID", + "tags": [ + "Keys" + ], + "summary": "Delete an API key", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "API key deleted successfully" + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/auth/keys/{id}/permissions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the instance-level permissions for a specific API key (includes instance names)", + "produces": [ + "application/json" + ], + "tags": [ + "Keys" + ], + "summary": "Get API key permissions", + "parameters": [ + { + "type": "integer", + "description": "Key ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "List of key permissions", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/server.KeyPermissionResponse" + } + } + }, + "400": { + "description": "Invalid key ID", + "schema": { + "type": "string" + } + }, + "404": { + "description": "API key not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/backends/llama-cpp/devices": { "get": { "security": [ @@ -1496,6 +1725,17 @@ } }, "definitions": { + "auth.PermissionMode": { + "type": "string", + "enum": [ + "allow_all", + "per_instance" + ], + "x-enum-varnames": [ + "PermissionModeAllowAll", + "PermissionModePerInstance" + ] + }, "config.AppConfig": { "type": "object", "properties": { @@ -1511,6 +1751,13 @@ "commit_hash": { "type": "string" }, + "data_dir": { + "description": "Directory where all llamactl data will be stored (database, instances, logs, etc.)", + "type": "string" + }, + "database": { + "$ref": "#/definitions/config.DatabaseConfig" + }, "instances": { "$ref": "#/definitions/config.InstancesConfig" }, @@ -1601,6 +1848,26 @@ } } }, + "config.DatabaseConfig": { + "type": "object", + "properties": { + "connection_max_lifetime": { + "type": "string", + "example": "1h" + }, + "max_idle_connections": { + "type": "integer" + }, + "max_open_connections": { + "description": "Connection settings", + "type": "integer" + }, + "path": { + "description": "Database file path (relative to the top-level data_dir or absolute)", + "type": "string" + } + } + }, "config.DockerSettings": { "type": "object", "properties": { @@ -1632,11 +1899,7 @@ "type": "boolean" }, "configs_dir": { - "description": "Instance config directory override", - "type": "string" - }, - "data_dir": { - "description": "Directory where all llamactl data will be stored (instances.json, logs, etc.)", + "description": "Instance config directory override (relative to data_dir if not absolute)", "type": "string" }, "default_auto_restart": { @@ -1660,7 +1923,7 @@ "type": "boolean" }, "logs_dir": { - "description": "Logs directory override", + "description": "Logs directory override (relative to data_dir if not absolute)", "type": "string" }, "max_instances": { @@ -1741,7 +2004,10 @@ "type": "object", "properties": { "created": { - "description": "Unix timestamp when the instance was created", + "description": "Unix timestamp when instance was created", + "type": "integer" + }, + "id": { "type": "integer" }, "name": { @@ -1787,6 +2053,125 @@ } } }, + "server.CreateKeyRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "integer", + "format": "int64" + }, + "instancePermissions": { + "type": "array", + "items": { + "$ref": "#/definitions/server.InstancePermission" + } + }, + "name": { + "type": "string" + }, + "permissionMode": { + "$ref": "#/definitions/auth.PermissionMode" + } + } + }, + "server.CreateKeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key": { + "type": "string" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, + "server.InstancePermission": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + } + } + }, + "server.KeyPermissionResponse": { + "type": "object", + "properties": { + "can_infer": { + "type": "boolean" + }, + "can_view_logs": { + "type": "boolean" + }, + "instance_id": { + "type": "integer" + }, + "instance_name": { + "type": "string" + } + } + }, + "server.KeyResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permission_mode": { + "$ref": "#/definitions/auth.PermissionMode" + }, + "updated_at": { + "type": "integer" + }, + "user_id": { + "type": "string" + } + } + }, "server.NodeResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2888ce1..8143bc3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,13 @@ basePath: /api/v1 definitions: + auth.PermissionMode: + enum: + - allow_all + - per_instance + type: string + x-enum-varnames: + - PermissionModeAllowAll + - PermissionModePerInstance config.AppConfig: properties: auth: @@ -10,6 +18,12 @@ definitions: type: string commit_hash: type: string + data_dir: + description: Directory where all llamactl data will be stored (database, instances, + logs, etc.) + type: string + database: + $ref: '#/definitions/config.DatabaseConfig' instances: $ref: '#/definitions/config.InstancesConfig' local_node: @@ -70,6 +84,20 @@ definitions: type: string type: object type: object + config.DatabaseConfig: + properties: + connection_max_lifetime: + example: 1h + type: string + max_idle_connections: + type: integer + max_open_connections: + description: Connection settings + type: integer + path: + description: Database file path (relative to the top-level data_dir or absolute) + type: string + type: object config.DockerSettings: properties: args: @@ -91,11 +119,8 @@ definitions: description: Automatically create the data directory if it doesn't exist type: boolean configs_dir: - description: Instance config directory override - type: string - data_dir: - description: Directory where all llamactl data will be stored (instances.json, - logs, etc.) + description: Instance config directory override (relative to data_dir if not + absolute) type: string default_auto_restart: description: Default auto-restart setting for new instances @@ -113,7 +138,7 @@ definitions: description: Enable LRU eviction for instance logs type: boolean logs_dir: - description: Logs directory override + description: Logs directory override (relative to data_dir if not absolute) type: string max_instances: description: Maximum number of instances that can be created @@ -171,7 +196,9 @@ definitions: instance.Instance: properties: created: - description: Unix timestamp when the instance was created + description: Unix timestamp when instance was created + type: integer + id: type: integer name: type: string @@ -203,6 +230,84 @@ definitions: description: seconds type: integer type: object + server.CreateKeyRequest: + properties: + expiresAt: + format: int64 + type: integer + instancePermissions: + items: + $ref: '#/definitions/server.InstancePermission' + type: array + name: + type: string + permissionMode: + $ref: '#/definitions/auth.PermissionMode' + type: object + server.CreateKeyResponse: + properties: + created_at: + type: integer + enabled: + type: boolean + expires_at: + type: integer + id: + type: integer + key: + type: string + last_used_at: + type: integer + name: + type: string + permission_mode: + $ref: '#/definitions/auth.PermissionMode' + updated_at: + type: integer + user_id: + type: string + type: object + server.InstancePermission: + properties: + can_infer: + type: boolean + can_view_logs: + type: boolean + instance_id: + type: integer + type: object + server.KeyPermissionResponse: + properties: + can_infer: + type: boolean + can_view_logs: + type: boolean + instance_id: + type: integer + instance_name: + type: string + type: object + server.KeyResponse: + properties: + created_at: + type: integer + enabled: + type: boolean + expires_at: + type: integer + id: + type: integer + last_used_at: + type: integer + name: + type: string + permission_mode: + $ref: '#/definitions/auth.PermissionMode' + updated_at: + type: integer + user_id: + type: string + type: object server.NodeResponse: properties: address: @@ -242,6 +347,156 @@ info: title: llamactl API version: "1.0" paths: + /api/v1/auth/keys: + get: + description: Returns a list of all API keys for the system user (excludes key + hash and plain-text key) + produces: + - application/json + responses: + "200": + description: List of API keys + schema: + items: + $ref: '#/definitions/server.KeyResponse' + type: array + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: List all API keys + tags: + - Keys + post: + consumes: + - application/json + description: Creates a new API key with the specified permissions and returns + the plain-text key (only shown once) + parameters: + - description: API key configuration + in: body + name: key + required: true + schema: + $ref: '#/definitions/server.CreateKeyRequest' + produces: + - application/json + responses: + "201": + description: Created API key with plain-text key + schema: + $ref: '#/definitions/server.CreateKeyResponse' + "400": + description: Invalid request body or validation error + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + summary: Create a new API key + tags: + - Keys + /api/v1/auth/keys/{id}: + delete: + description: Deletes an API key by ID + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: API key deleted successfully + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Delete an API key + tags: + - Keys + get: + description: Returns details for a specific API key by ID (excludes key hash + and plain-text key) + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: API key details + schema: + $ref: '#/definitions/server.KeyResponse' + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Get details of a specific API key + tags: + - Keys + /api/v1/auth/keys/{id}/permissions: + get: + description: Returns the instance-level permissions for a specific API key (includes + instance names) + parameters: + - description: Key ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: List of key permissions + schema: + items: + $ref: '#/definitions/server.KeyPermissionResponse' + type: array + "400": + description: Invalid key ID + schema: + type: string + "404": + description: API key not found + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + security: + - ApiKeyAuth: [] + summary: Get API key permissions + tags: + - Keys /api/v1/backends/llama-cpp/devices: get: description: Returns a list of available devices for the llama server diff --git a/pkg/config/config.go b/pkg/config/config.go index 1c53c17..3f6a35c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -85,7 +85,7 @@ type DatabaseConfig struct { // Connection settings MaxOpenConnections int `yaml:"max_open_connections" json:"max_open_connections"` MaxIdleConnections int `yaml:"max_idle_connections" json:"max_idle_connections"` - ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime"` + ConnMaxLifetime time.Duration `yaml:"connection_max_lifetime" json:"connection_max_lifetime" swaggertype:"string" example:"1h"` } // InstancesConfig contains instance management configuration diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index 2be79b0..70c5e20 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -11,12 +11,14 @@ import ( "github.com/go-chi/chi/v5" ) +// InstancePermission defines the permissions for an API key on a specific instance. type InstancePermission struct { InstanceID int `json:"instance_id"` CanInfer bool `json:"can_infer"` CanViewLogs bool `json:"can_view_logs"` } +// CreateKeyRequest represents the request body for creating a new API key. type CreateKeyRequest struct { Name string PermissionMode auth.PermissionMode @@ -24,31 +26,34 @@ type CreateKeyRequest struct { InstancePermissions []InstancePermission } +// CreateKeyResponse represents the response returned when creating a new API key. type CreateKeyResponse struct { - ID int `json:"id"` - Name string `json:"name"` - UserID string `json:"user_id"` - PermissionMode auth.PermissionMode `json:"permission_mode"` - ExpiresAt *int64 `json:"expires_at"` - Enabled bool `json:"enabled"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - LastUsedAt *int64 `json:"last_used_at"` - Key string `json:"key"` + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` + Key string `json:"key"` } +// KeyResponse represents an API key in responses for list and get operations. type KeyResponse struct { - ID int `json:"id"` - Name string `json:"name"` - UserID string `json:"user_id"` - PermissionMode auth.PermissionMode `json:"permission_mode"` - ExpiresAt *int64 `json:"expires_at"` - Enabled bool `json:"enabled"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - LastUsedAt *int64 `json:"last_used_at"` + ID int `json:"id"` + Name string `json:"name"` + UserID string `json:"user_id"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + LastUsedAt *int64 `json:"last_used_at"` } +// KeyPermissionResponse represents the permissions for an API key on a specific instance. type KeyPermissionResponse struct { InstanceID int `json:"instance_id"` InstanceName string `json:"instance_name"` @@ -56,8 +61,18 @@ type KeyPermissionResponse struct { CanViewLogs bool `json:"can_view_logs"` } -// CreateInferenceKey handles POST /api/v1/keys -func (h *Handler) CreateInferenceKey() http.HandlerFunc { +// CreateKey godoc +// @Summary Create a new API key +// @Description Creates a new API key with the specified permissions and returns the plain-text key (only shown once) +// @Tags Keys +// @Accept json +// @Produce json +// @Param key body CreateKeyRequest true "API key configuration" +// @Success 201 {object} CreateKeyResponse "Created API key with plain-text key" +// @Failure 400 {string} string "Invalid request body or validation error" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys [post] +func (h *Handler) CreateKey() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req CreateKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -172,8 +187,16 @@ func (h *Handler) CreateInferenceKey() http.HandlerFunc { } } -// ListInferenceKeys handles GET /api/v1/keys -func (h *Handler) ListInferenceKeys() http.HandlerFunc { +// ListKeys godoc +// @Summary List all API keys +// @Description Returns a list of all API keys for the system user (excludes key hash and plain-text key) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Success 200 {array} KeyResponse "List of API keys" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys [get] +func (h *Handler) ListKeys() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { keys, err := h.authStore.GetUserKeys(r.Context(), "system") if err != nil { @@ -202,8 +225,19 @@ func (h *Handler) ListInferenceKeys() http.HandlerFunc { } } -// GetInferenceKey handles GET /api/v1/keys/{id} -func (h *Handler) GetInferenceKey() http.HandlerFunc { +// GetKey godoc +// @Summary Get details of a specific API key +// @Description Returns details for a specific API key by ID (excludes key hash and plain-text key) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Param id path int true "Key ID" +// @Success 200 {object} KeyResponse "API key details" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id} [get] +func (h *Handler) GetKey() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) @@ -240,8 +274,18 @@ func (h *Handler) GetInferenceKey() http.HandlerFunc { } } -// DeleteInferenceKey handles DELETE /api/v1/keys/{id} -func (h *Handler) DeleteInferenceKey() http.HandlerFunc { +// DeleteKey godoc +// @Summary Delete an API key +// @Description Deletes an API key by ID +// @Tags Keys +// @Security ApiKeyAuth +// @Param id path int true "Key ID" +// @Success 204 "API key deleted successfully" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id} [delete] +func (h *Handler) DeleteKey() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) @@ -264,8 +308,19 @@ func (h *Handler) DeleteInferenceKey() http.HandlerFunc { } } -// GetInferenceKeyPermissions handles GET /api/v1/keys/{id}/permissions -func (h *Handler) GetInferenceKeyPermissions() http.HandlerFunc { +// GetKeyPermissions godoc +// @Summary Get API key permissions +// @Description Returns the instance-level permissions for a specific API key (includes instance names) +// @Tags Keys +// @Security ApiKeyAuth +// @Produce json +// @Param id path int true "Key ID" +// @Success 200 {array} KeyPermissionResponse "List of key permissions" +// @Failure 400 {string} string "Invalid key ID" +// @Failure 404 {string} string "API key not found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/auth/keys/{id}/permissions [get] +func (h *Handler) GetKeyPermissions() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 6920a61..f5825da 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -49,11 +49,11 @@ func SetupRouter(handler *Handler) *chi.Mux { // API key management endpoints r.Route("/auth", func(r chi.Router) { r.Route("/keys", func(r chi.Router) { - r.Post("/", handler.CreateInferenceKey()) // Create API key - r.Get("/", handler.ListInferenceKeys()) // List API keys - r.Get("/{id}", handler.GetInferenceKey()) // Get API key details - r.Delete("/{id}", handler.DeleteInferenceKey()) // Delete API key - r.Get("/{id}/permissions", handler.GetInferenceKeyPermissions()) // Get key permissions + r.Post("/", handler.CreateKey()) // Create API key + r.Get("/", handler.ListKeys()) // List API keys + r.Get("/{id}", handler.GetKey()) // Get API key details + r.Delete("/{id}", handler.DeleteKey()) // Delete API key + r.Get("/{id}/permissions", handler.GetKeyPermissions()) // Get key permissions }) }) From d9c666a2458525164b37ea237090f21f40624d31 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 4 Dec 2025 21:23:22 +0100 Subject: [PATCH 04/24] Update deprication warnings --- pkg/config/config.go | 10 +++++++++- pkg/server/middleware.go | 6 ------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 3f6a35c..0f49f58 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -93,7 +93,6 @@ type InstancesConfig struct { // Port range for instances (e.g., 8000,9000) PortRange [2]int `yaml:"port_range" json:"port_range"` - // Instance config directory override (relative to data_dir if not absolute) InstancesDir string `yaml:"configs_dir" json:"configs_dir"` @@ -248,9 +247,18 @@ func LoadConfig(configPath string) (AppConfig, error) { // 3. Override with environment variables loadEnvVars(&cfg) + // Log warning if deprecated inference keys are present + if len(cfg.Auth.InferenceKeys) > 0 { + log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.") + log.Println(" Please create inference keys in web UI or via management API.") + } + // Set default directories if not specified if cfg.Instances.InstancesDir == "" { cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances") + } else { + // Log deprecation warning if using custom instances dir + log.Println("⚠️ Instances directory is deprecated and will be removed in future versions. Instances are persisted in the database.") } if cfg.Instances.LogsDir == "" { cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs") diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index cd4f24f..7d8e53e 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -36,12 +36,6 @@ func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStor managementKeys[key] = true } - // If len(authCfg.InferenceKeys) > 0, log warning - if len(authCfg.InferenceKeys) > 0 { - log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.") - log.Println(" Please create inference keys in web UI or via management API.") - } - // Handle legacy auto-generation for management keys if none provided and auth is required var generated bool = false const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" From 991ce3c6789130fdd79101c995d8a4ca234eb264 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 4 Dec 2025 22:18:29 +0100 Subject: [PATCH 05/24] Remove unnecessary canviewlogs permission --- pkg/auth/key.go | 7 +++---- pkg/database/apikeys.go | 6 +++--- pkg/database/migrations/001_initial_schema.up.sql | 1 - pkg/database/permissions.go | 4 ++-- pkg/server/handlers_auth.go | 14 +++++--------- pkg/server/routes.go | 2 +- 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/pkg/auth/key.go b/pkg/auth/key.go index 9485c1b..3f05119 100644 --- a/pkg/auth/key.go +++ b/pkg/auth/key.go @@ -27,10 +27,9 @@ type APIKey struct { } type KeyPermission struct { - KeyID int - InstanceID int - CanInfer bool - CanViewLogs bool + KeyID int + InstanceID int + CanInfer bool } // GenerateKey generates a cryptographically secure API key with the given prefix diff --git a/pkg/database/apikeys.go b/pkg/database/apikeys.go index 940bc62..ed6fe27 100644 --- a/pkg/database/apikeys.go +++ b/pkg/database/apikeys.go @@ -45,10 +45,10 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions if key.PermissionMode == auth.PermissionModePerInstance { for _, perm := range permissions { query := ` - INSERT INTO key_permissions (key_id, instance_id, can_infer, can_view_logs) - VALUES (?, ?, ?, ?) + INSERT INTO key_permissions (key_id, instance_id, can_infer) + VALUES (?, ?, ?) ` - _, err := tx.ExecContext(ctx, query, perm.KeyID, perm.InstanceID, perm.CanInfer, perm.CanViewLogs) + _, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID, perm.CanInfer) if err != nil { return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err) } diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 2338a82..5c71a81 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -49,7 +49,6 @@ CREATE TABLE IF NOT EXISTS key_permissions ( key_id INTEGER NOT NULL, instance_id INTEGER NOT NULL, can_infer INTEGER NOT NULL DEFAULT 0, - can_view_logs INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (key_id, instance_id), FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE, FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE diff --git a/pkg/database/permissions.go b/pkg/database/permissions.go index afd746b..8000e6d 100644 --- a/pkg/database/permissions.go +++ b/pkg/database/permissions.go @@ -10,7 +10,7 @@ import ( // GetPermissions retrieves all permissions for a key func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) { query := ` - SELECT key_id, instance_id, can_infer, can_view_logs + SELECT key_id, instance_id, can_infer FROM key_permissions WHERE key_id = ? ORDER BY instance_id @@ -25,7 +25,7 @@ func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPe var permissions []auth.KeyPermission for rows.Next() { var perm auth.KeyPermission - err := rows.Scan(&perm.KeyID, &perm.InstanceID, &perm.CanInfer, &perm.CanViewLogs) + err := rows.Scan(&perm.KeyID, &perm.InstanceID, &perm.CanInfer) if err != nil { return nil, fmt.Errorf("failed to scan key permission: %w", err) } diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index 70c5e20..03ae23c 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -13,9 +13,8 @@ import ( // InstancePermission defines the permissions for an API key on a specific instance. type InstancePermission struct { - InstanceID int `json:"instance_id"` - CanInfer bool `json:"can_infer"` - CanViewLogs bool `json:"can_view_logs"` + InstanceID int `json:"instance_id"` + CanInfer bool `json:"can_infer"` } // CreateKeyRequest represents the request body for creating a new API key. @@ -58,7 +57,6 @@ type KeyPermissionResponse struct { InstanceID int `json:"instance_id"` InstanceName string `json:"instance_name"` CanInfer bool `json:"can_infer"` - CanViewLogs bool `json:"can_view_logs"` } // CreateKey godoc @@ -153,10 +151,9 @@ func (h *Handler) CreateKey() http.HandlerFunc { var keyPermissions []auth.KeyPermission for _, perm := range req.InstancePermissions { keyPermissions = append(keyPermissions, auth.KeyPermission{ - KeyID: 0, // Will be set by database after key creation - InstanceID: perm.InstanceID, - CanInfer: perm.CanInfer, - CanViewLogs: perm.CanViewLogs, + KeyID: 0, // Will be set by database after key creation + InstanceID: perm.InstanceID, + CanInfer: perm.CanInfer, }) } @@ -363,7 +360,6 @@ func (h *Handler) GetKeyPermissions() http.HandlerFunc { InstanceID: perm.InstanceID, InstanceName: instanceNameMap[perm.InstanceID], CanInfer: perm.CanInfer, - CanViewLogs: perm.CanViewLogs, }) } diff --git a/pkg/server/routes.go b/pkg/server/routes.go index f5825da..d8f8c35 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -78,7 +78,7 @@ func SetupRouter(handler *Handler) *chi.Mux { r.Get("/", handler.ListNodes()) // List all nodes r.Route("/{name}", func(r chi.Router) { - r.Get("/", handler.GetNode()) + r.Get("/", handler.GetNode()) // Get node details }) }) From a1b6f0c1b0236b9d56c8f55e36654f5f4e5ff6d5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 4 Dec 2025 23:02:06 +0100 Subject: [PATCH 06/24] Remove JSON file archiving from migration process --- cmd/server/migrate_json.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cmd/server/migrate_json.go b/cmd/server/migrate_json.go index eb14781..cd1955b 100644 --- a/cmd/server/migrate_json.go +++ b/cmd/server/migrate_json.go @@ -58,20 +58,6 @@ func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error { log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files)) - // Archive old JSON files - if migrated > 0 { - archiveDir := filepath.Join(instancesDir, "json_archive") - if err := os.MkdirAll(archiveDir, 0755); err == nil { - for _, file := range files { - newPath := filepath.Join(archiveDir, filepath.Base(file)) - if err := os.Rename(file, newPath); err != nil { - log.Printf("Failed to archive %s: %v", file, err) - } - } - log.Printf("Archived old JSON files to %s", archiveDir) - } - } - return nil } From 2d0acc60f2e2311ea40bac636aeccd8190cfc859 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 4 Dec 2025 23:25:51 +0100 Subject: [PATCH 07/24] Fix double dash in generated keys --- pkg/server/handlers_auth.go | 2 +- pkg/server/middleware.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index 03ae23c..dddc336 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -121,7 +121,7 @@ func (h *Handler) CreateKey() http.HandlerFunc { } // Generate plain-text key - plainTextKey, err := auth.GenerateKey("llamactl-") + plainTextKey, err := auth.GenerateKey("llamactl") if err != nil { writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key") return diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 7d8e53e..3e0ae91 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -41,7 +41,7 @@ func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStor const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 { - key, err := auth.GenerateKey("llamactl-mgmt-") + key, err := auth.GenerateKey("llamactl-mgmt") if err != nil { log.Printf("Warning: Failed to generate management key: %v", err) // Fallback to PID-based key for safety From 80d5d44a0b67c7ddb984eb7410c1959354841b39 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 4 Dec 2025 23:26:32 +0100 Subject: [PATCH 08/24] Add inference api key frontend integration --- webui/package-lock.json | 184 +++++++++-- webui/package.json | 2 + webui/src/App.tsx | 19 +- webui/src/__tests__/App.test.tsx | 6 +- webui/src/components/Header.tsx | 15 +- .../__tests__/InstanceCard.test.tsx | 4 + .../__tests__/InstanceList.test.tsx | 6 +- .../__tests__/InstanceModal.test.tsx | 1 + .../components/apikeys/CreateApiKeyDialog.tsx | 236 +++++++++++++++ .../components/settings/ApiKeysSection.tsx | 285 ++++++++++++++++++ .../components/settings/SettingsDialog.tsx | 22 ++ webui/src/components/ui/alert.tsx | 59 ++++ webui/src/components/ui/radio-group.tsx | 42 +++ .../__tests__/InstancesContext.test.tsx | 7 +- webui/src/lib/api.ts | 27 ++ webui/src/types/apiKey.ts | 38 +++ webui/src/types/instance.ts | 1 + 17 files changed, 921 insertions(+), 33 deletions(-) create mode 100644 webui/src/components/apikeys/CreateApiKeyDialog.tsx create mode 100644 webui/src/components/settings/ApiKeysSection.tsx create mode 100644 webui/src/components/settings/SettingsDialog.tsx create mode 100644 webui/src/components/ui/alert.tsx create mode 100644 webui/src/components/ui/radio-group.tsx create mode 100644 webui/src/types/apiKey.ts diff --git a/webui/package-lock.json b/webui/package-lock.json index 0d657f7..cd6b0ea 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -12,10 +12,12 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -160,7 +162,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -510,7 +511,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -554,7 +554,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1286,6 +1285,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1352,6 +1377,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", @@ -1531,6 +1571,105 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -2352,7 +2491,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2447,7 +2587,6 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2458,7 +2597,6 @@ "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2469,7 +2607,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2520,7 +2657,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2869,7 +3005,6 @@ "integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.8", "fflate": "^0.8.2", @@ -2906,7 +3041,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2957,6 +3091,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3228,7 +3363,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3540,6 +3674,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3651,7 +3795,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3955,7 +4100,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5181,7 +5325,6 @@ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -5592,6 +5735,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5977,7 +6121,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6039,6 +6182,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6054,6 +6198,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6095,7 +6240,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6105,7 +6249,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6118,7 +6261,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7072,7 +7216,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7220,7 +7363,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7296,7 +7438,6 @@ "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.8", "@vitest/mocker": "4.0.8", @@ -7625,7 +7766,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/package.json b/webui/package.json index e998fba..a66e024 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,10 +21,12 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-slot": "^1.2.3", "@tailwindcss/vite": "^4.1.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 04c8c01..fc62225 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -4,6 +4,7 @@ import InstanceList from "@/components/InstanceList"; import InstanceDialog from "@/components/InstanceDialog"; import LoginDialog from "@/components/LoginDialog"; import SystemInfoDialog from "./components/SystemInfoDialog"; +import SettingsDialog from "./components/settings/SettingsDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -14,6 +15,7 @@ function App() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [editingInstance, setEditingInstance] = useState( undefined ); @@ -41,6 +43,10 @@ function App() { setIsSystemInfoModalOpen(true); }; + const handleShowSettings = () => { + setIsSettingsModalOpen(true); + }; + // Show loading spinner while checking auth if (authLoading) { return ( @@ -70,7 +76,11 @@ function App() { return (
-
+
@@ -86,7 +96,12 @@ function App() { open={isSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen} /> - + + +
diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index eb212a4..2aa39f6 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -75,8 +75,8 @@ function renderApp() { describe('App Component - Critical Business Logic Only', () => { const mockInstances: Instance[] = [ - { name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, - { name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } + { id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, + { id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } ] beforeEach(() => { @@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => { it('creates new instance with correct API call and updates UI', async () => { const user = userEvent.setup() const newInstance: Instance = { + id: 3, name: 'new-test-instance', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } } @@ -151,6 +152,7 @@ describe('App Component - Critical Business Logic Only', () => { it('updates existing instance with correct API call', async () => { const user = userEvent.setup() const updatedInstance: Instance = { + id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } } diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index 3c7e0e1..d2af49f 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,14 +1,15 @@ import { Button } from "@/components/ui/button"; -import { HelpCircle, LogOut, Moon, Sun } from "lucide-react"; +import { HelpCircle, LogOut, Moon, Settings, Sun } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { useTheme } from "@/contexts/ThemeContext"; interface HeaderProps { onCreateInstance: () => void; onShowSystemInfo: () => void; + onShowSettings: () => void; } -function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { +function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) { const { logout } = useAuth(); const { theme, toggleTheme } = useTheme(); @@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { {theme === 'light' ? : } + + + + + + + + ); +} + +export default CreateApiKeyDialog; diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..26fe928 --- /dev/null +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react"; +import { apiKeysApi } from "@/lib/api"; +import { ApiKey, KeyPermissionResponse, PermissionMode } from "@/types/apiKey"; +import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog"; +import { format, formatDistanceToNow } from "date-fns"; + +function ApiKeysSection() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedRowId, setExpandedRowId] = useState(null); + const [newKeyPlainText, setNewKeyPlainText] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [permissions, setPermissions] = useState>({}); + const [loadingPermissions, setLoadingPermissions] = useState>({}); + + useEffect(() => { + fetchKeys(); + }, []); + + const fetchKeys = async () => { + setLoading(true); + setError(null); + try { + const data = await apiKeysApi.list(); + setKeys(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load API keys"); + } finally { + setLoading(false); + } + }; + + const fetchPermissions = async (keyId: number) => { + if (permissions[keyId]) return; + + setLoadingPermissions({ ...loadingPermissions, [keyId]: true }); + try { + const data = await apiKeysApi.getPermissions(keyId); + setPermissions({ ...permissions, [keyId]: data }); + } catch (err) { + console.error("Failed to load permissions:", err); + } finally { + setLoadingPermissions({ ...loadingPermissions, [keyId]: false }); + } + }; + + const handleKeyCreated = (plainTextKey: string) => { + setNewKeyPlainText(plainTextKey); + fetchKeys(); + setCreateDialogOpen(false); + }; + + const dismissSuccessBanner = () => { + setNewKeyPlainText(null); + }; + + const handleCopyKey = async () => { + if (newKeyPlainText) { + await navigator.clipboard.writeText(newKeyPlainText); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + } + }; + + const handleDeleteKey = async (id: number, name: string) => { + if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) { + return; + } + + try { + await apiKeysApi.delete(id); + fetchKeys(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete API key"); + } + }; + + const handleRowClick = (key: ApiKey) => { + if (expandedRowId === key.id) { + setExpandedRowId(null); + } else { + setExpandedRowId(key.id); + if (key.permission_mode === PermissionMode.PerInstance) { + fetchPermissions(key.id); + } + } + }; + + const formatDate = (timestamp: number) => { + return format(new Date(timestamp * 1000), "MMM d, yyyy"); + }; + + const formatLastUsed = (timestamp: number | null) => { + if (!timestamp) return "Never"; + return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true }); + }; + + const isExpired = (expiresAt: number | null) => { + if (!expiresAt) return false; + return expiresAt * 1000 < Date.now(); + }; + + return ( +
+
+

API Keys

+ +
+ + {newKeyPlainText && ( + + +
+
+

API key created successfully

+

+ Make sure to copy this key now. You won't be able to see it again! +

+
+ +
+
+ + {newKeyPlainText} + + +
+
+
+ )} + + {error && ( + + {error} + + )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : keys.length === 0 ? ( +
+ No API keys yet. Create your first key to get started. +
+ ) : ( +
+ + + + + + + + + + + + + {keys.map((key) => ( + <> + handleRowClick(key)} + > + + + + + + + + {expandedRowId === key.id && ( + + + + )} + + ))} + +
NamePermissionsCreatedExpiresLast AccessedActions
+
+ {expandedRowId === key.id ? ( + + ) : ( + + )} + {key.name} +
+
+ {key.permission_mode === PermissionMode.AllowAll ? ( + Full Access + ) : ( + Limited Access + )} + {formatDate(key.created_at)} + {key.expires_at ? ( + isExpired(key.expires_at) ? ( + Expired + ) : ( + {formatDate(key.expires_at)} + ) + ) : ( + Never + )} + {formatLastUsed(key.last_used_at)} + +
+ {key.permission_mode === PermissionMode.AllowAll ? ( +

+ This key has full access to all instances +

+ ) : loadingPermissions[key.id] ? ( +

Loading permissions...

+ ) : permissions[key.id] ? ( +
+

Instance Permissions:

+ + + + + + + + + {permissions[key.id].map((perm) => ( + + + + + ))} + +
Instance NameCan Infer
{perm.instance_name} + {perm.can_infer ? ( + + ) : ( + + )} +
+
+ ) : ( +

No permissions data

+ )} +
+
+ )} + + +
+ ); +} + +export default ApiKeysSection; diff --git a/webui/src/components/settings/SettingsDialog.tsx b/webui/src/components/settings/SettingsDialog.tsx new file mode 100644 index 0000000..9bd2e18 --- /dev/null +++ b/webui/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,22 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import ApiKeysSection from "./ApiKeysSection"; + +interface SettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + return ( + + + + Settings + + + + + ); +} + +export default SettingsDialog; diff --git a/webui/src/components/ui/alert.tsx b/webui/src/components/ui/alert.tsx new file mode 100644 index 0000000..fd81ebc --- /dev/null +++ b/webui/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/webui/src/components/ui/radio-group.tsx b/webui/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..43b43b4 --- /dev/null +++ b/webui/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index 1920d6a..cad603d 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -123,8 +123,8 @@ function renderWithProvider(children: ReactNode) { describe("InstancesContext", () => { const mockInstances: Instance[] = [ - { name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } }, - { name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } }, + { id: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } }, + { id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } }, ]; beforeEach(() => { @@ -181,6 +181,7 @@ describe("InstancesContext", () => { describe("Create Instance", () => { it("creates instance and adds it to state", async () => { const newInstance: Instance = { + id: 3, name: "new-instance", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } }, @@ -238,6 +239,7 @@ describe("InstancesContext", () => { describe("Update Instance", () => { it("updates instance and maintains it in state", async () => { const updatedInstance: Instance = { + id: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } }, @@ -408,6 +410,7 @@ describe("InstancesContext", () => { it("maintains consistent state during multiple operations", async () => { // Test that operations don't interfere with each other const newInstance: Instance = { + id: 3, name: "new-instance", status: "stopped", options: {}, diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 2ac679c..ddcf384 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,5 +1,6 @@ import type { CreateInstanceOptions, Instance } from "@/types/instance"; import type { AppConfig } from "@/types/config"; +import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey"; import { handleApiError } from "./errorUtils"; // Adding baseURI as a prefix to support being served behind a subpath @@ -178,3 +179,29 @@ export const instancesApi = { // GET /instances/{name}/proxy/health getHealth: (name: string) => apiCall>(`/instances/${encodeURIComponent(name)}/proxy/health`), }; + +// API Keys API functions +export const apiKeysApi = { + // GET /auth/keys + list: () => apiCall("/auth/keys"), + + // GET /auth/keys/{id} + get: (id: number) => apiCall(`/auth/keys/${id}`), + + // POST /auth/keys + create: (request: CreateKeyRequest) => + apiCall("/auth/keys", { + method: "POST", + body: JSON.stringify(request), + }), + + // DELETE /auth/keys/{id} + delete: (id: number) => + apiCall(`/auth/keys/${id}`, { + method: "DELETE", + }), + + // GET /auth/keys/{id}/permissions + getPermissions: (id: number) => + apiCall(`/auth/keys/${id}/permissions`), +}; diff --git a/webui/src/types/apiKey.ts b/webui/src/types/apiKey.ts new file mode 100644 index 0000000..21a758d --- /dev/null +++ b/webui/src/types/apiKey.ts @@ -0,0 +1,38 @@ +export enum PermissionMode { + AllowAll = "allow_all", + PerInstance = "per_instance" +} + +export interface ApiKey { + id: number + name: string + user_id: string + permission_mode: PermissionMode + expires_at: number | null + enabled: boolean + created_at: number + updated_at: number + last_used_at: number | null +} + +export interface CreateKeyRequest { + Name: string + PermissionMode: PermissionMode + ExpiresAt?: number + InstancePermissions: InstancePermission[] +} + +export interface InstancePermission { + InstanceID: number + CanInfer: boolean +} + +export interface CreateKeyResponse extends ApiKey { + key: string +} + +export interface KeyPermissionResponse { + instance_id: number + instance_name: string + can_infer: boolean +} diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 0977233..27e54a5 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -24,6 +24,7 @@ export interface HealthStatus { } export interface Instance { + id: number; name: string; status: InstanceStatus; options?: CreateInstanceOptions; From c37c1b8161bf92036fddb74480d1c3c7d4b2b342 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 17:59:11 +0100 Subject: [PATCH 09/24] Remove 'enabled' field from API key model and related database operations --- pkg/auth/key.go | 1 - pkg/database/apikeys.go | 22 +++++++++---------- .../migrations/001_initial_schema.up.sql | 1 - pkg/server/handlers_auth.go | 6 ----- webui/src/types/apiKey.ts | 1 - 5 files changed, 11 insertions(+), 20 deletions(-) diff --git a/pkg/auth/key.go b/pkg/auth/key.go index 3f05119..830a196 100644 --- a/pkg/auth/key.go +++ b/pkg/auth/key.go @@ -20,7 +20,6 @@ type APIKey struct { UserID string PermissionMode PermissionMode ExpiresAt *int64 - Enabled bool CreatedAt int64 UpdatedAt int64 LastUsedAt *int64 diff --git a/pkg/database/apikeys.go b/pkg/database/apikeys.go index ed6fe27..1304d61 100644 --- a/pkg/database/apikeys.go +++ b/pkg/database/apikeys.go @@ -18,8 +18,8 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions // Insert the API key query := ` - INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ` var expiresAt sql.NullInt64 @@ -29,7 +29,7 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions result, err := tx.ExecContext(ctx, query, key.KeyHash, key.Name, key.UserID, key.PermissionMode, - expiresAt, key.Enabled, key.CreatedAt, key.UpdatedAt, + expiresAt, key.CreatedAt, key.UpdatedAt, ) if err != nil { return fmt.Errorf("failed to insert API key: %w", err) @@ -61,7 +61,7 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions // GetKeyByID retrieves an API key by ID func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) { query := ` - SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at FROM api_keys WHERE id = ? ` @@ -72,7 +72,7 @@ func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error err := db.QueryRowContext(ctx, query, id).Scan( &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, - &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, ) if err != nil { if err == sql.ErrNoRows { @@ -94,7 +94,7 @@ func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error // GetUserKeys retrieves all API keys for a user func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) { query := ` - SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC @@ -114,7 +114,7 @@ func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.API err := rows.Scan( &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, - &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan API key: %w", err) @@ -133,12 +133,12 @@ func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.API return keys, nil } -// GetActiveKeys retrieves all enabled, non-expired API keys +// GetActiveKeys retrieves all non-expired API keys func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) { query := ` - SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at + SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at FROM api_keys - WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) + WHERE expires_at IS NULL OR expires_at > ? ORDER BY created_at DESC ` @@ -157,7 +157,7 @@ func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) { err := rows.Scan( &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, - &expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, + &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan API key: %w", err) diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index 5c71a81..a9dc4e4 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -36,7 +36,6 @@ CREATE TABLE IF NOT EXISTS api_keys ( user_id TEXT NOT NULL, permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance', expires_at INTEGER NULL, - enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_used_at INTEGER NULL diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index dddc336..fb1a7fb 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -32,7 +32,6 @@ type CreateKeyResponse struct { UserID string `json:"user_id"` PermissionMode auth.PermissionMode `json:"permission_mode"` ExpiresAt *int64 `json:"expires_at"` - Enabled bool `json:"enabled"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` LastUsedAt *int64 `json:"last_used_at"` @@ -46,7 +45,6 @@ type KeyResponse struct { UserID string `json:"user_id"` PermissionMode auth.PermissionMode `json:"permission_mode"` ExpiresAt *int64 `json:"expires_at"` - Enabled bool `json:"enabled"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` LastUsedAt *int64 `json:"last_used_at"` @@ -142,7 +140,6 @@ func (h *Handler) CreateKey() http.HandlerFunc { UserID: "system", PermissionMode: req.PermissionMode, ExpiresAt: req.ExpiresAt, - Enabled: true, CreatedAt: now, UpdatedAt: now, } @@ -171,7 +168,6 @@ func (h *Handler) CreateKey() http.HandlerFunc { UserID: apiKey.UserID, PermissionMode: apiKey.PermissionMode, ExpiresAt: apiKey.ExpiresAt, - Enabled: apiKey.Enabled, CreatedAt: apiKey.CreatedAt, UpdatedAt: apiKey.UpdatedAt, LastUsedAt: apiKey.LastUsedAt, @@ -210,7 +206,6 @@ func (h *Handler) ListKeys() http.HandlerFunc { UserID: key.UserID, PermissionMode: key.PermissionMode, ExpiresAt: key.ExpiresAt, - Enabled: key.Enabled, CreatedAt: key.CreatedAt, UpdatedAt: key.UpdatedAt, LastUsedAt: key.LastUsedAt, @@ -260,7 +255,6 @@ func (h *Handler) GetKey() http.HandlerFunc { UserID: key.UserID, PermissionMode: key.PermissionMode, ExpiresAt: key.ExpiresAt, - Enabled: key.Enabled, CreatedAt: key.CreatedAt, UpdatedAt: key.UpdatedAt, LastUsedAt: key.LastUsedAt, diff --git a/webui/src/types/apiKey.ts b/webui/src/types/apiKey.ts index 21a758d..2f3d786 100644 --- a/webui/src/types/apiKey.ts +++ b/webui/src/types/apiKey.ts @@ -9,7 +9,6 @@ export interface ApiKey { user_id: string permission_mode: PermissionMode expires_at: number | null - enabled: boolean created_at: number updated_at: number last_used_at: number | null From 99927160c2fd551589d314165e50a98ad2a6a285 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 18:07:01 +0100 Subject: [PATCH 10/24] Remove 'can_infer' field --- pkg/auth/key.go | 1 - pkg/database/apikeys.go | 6 +++--- pkg/database/migrations/001_initial_schema.up.sql | 1 - pkg/database/permissions.go | 14 +++++++------- pkg/server/handlers_auth.go | 6 +----- webui/src/components/settings/ApiKeysSection.tsx | 6 +----- webui/src/types/apiKey.ts | 2 -- 7 files changed, 12 insertions(+), 24 deletions(-) diff --git a/pkg/auth/key.go b/pkg/auth/key.go index 830a196..3771a23 100644 --- a/pkg/auth/key.go +++ b/pkg/auth/key.go @@ -28,7 +28,6 @@ type APIKey struct { type KeyPermission struct { KeyID int InstanceID int - CanInfer bool } // GenerateKey generates a cryptographically secure API key with the given prefix diff --git a/pkg/database/apikeys.go b/pkg/database/apikeys.go index 1304d61..8748ad5 100644 --- a/pkg/database/apikeys.go +++ b/pkg/database/apikeys.go @@ -45,10 +45,10 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions if key.PermissionMode == auth.PermissionModePerInstance { for _, perm := range permissions { query := ` - INSERT INTO key_permissions (key_id, instance_id, can_infer) - VALUES (?, ?, ?) + INSERT INTO key_permissions (key_id, instance_id) + VALUES (?, ?) ` - _, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID, perm.CanInfer) + _, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID) if err != nil { return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err) } diff --git a/pkg/database/migrations/001_initial_schema.up.sql b/pkg/database/migrations/001_initial_schema.up.sql index a9dc4e4..299463e 100644 --- a/pkg/database/migrations/001_initial_schema.up.sql +++ b/pkg/database/migrations/001_initial_schema.up.sql @@ -47,7 +47,6 @@ CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS key_permissions ( key_id INTEGER NOT NULL, instance_id INTEGER NOT NULL, - can_infer INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (key_id, instance_id), FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE, FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE diff --git a/pkg/database/permissions.go b/pkg/database/permissions.go index 8000e6d..a8602e1 100644 --- a/pkg/database/permissions.go +++ b/pkg/database/permissions.go @@ -10,7 +10,7 @@ import ( // GetPermissions retrieves all permissions for a key func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) { query := ` - SELECT key_id, instance_id, can_infer + SELECT key_id, instance_id FROM key_permissions WHERE key_id = ? ORDER BY instance_id @@ -25,7 +25,7 @@ func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPe var permissions []auth.KeyPermission for rows.Next() { var perm auth.KeyPermission - err := rows.Scan(&perm.KeyID, &perm.InstanceID, &perm.CanInfer) + err := rows.Scan(&perm.KeyID, &perm.InstanceID) if err != nil { return nil, fmt.Errorf("failed to scan key permission: %w", err) } @@ -38,13 +38,13 @@ func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPe // HasPermission checks if key has inference permission for instance func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) { query := ` - SELECT can_infer - FROM key_permissions + SELECT 1 + FROM key_permissions WHERE key_id = ? AND instance_id = ? ` - var canInfer bool - err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&canInfer) + var exists int + err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&exists) if err != nil { if err == sql.ErrNoRows { // No permission record found, deny access @@ -53,5 +53,5 @@ func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (b return false, fmt.Errorf("failed to check key permission: %w", err) } - return canInfer, nil + return true, nil } diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index fb1a7fb..7b77906 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -13,8 +13,7 @@ import ( // InstancePermission defines the permissions for an API key on a specific instance. type InstancePermission struct { - InstanceID int `json:"instance_id"` - CanInfer bool `json:"can_infer"` + InstanceID int `json:"instance_id"` } // CreateKeyRequest represents the request body for creating a new API key. @@ -54,7 +53,6 @@ type KeyResponse struct { type KeyPermissionResponse struct { InstanceID int `json:"instance_id"` InstanceName string `json:"instance_name"` - CanInfer bool `json:"can_infer"` } // CreateKey godoc @@ -150,7 +148,6 @@ func (h *Handler) CreateKey() http.HandlerFunc { keyPermissions = append(keyPermissions, auth.KeyPermission{ KeyID: 0, // Will be set by database after key creation InstanceID: perm.InstanceID, - CanInfer: perm.CanInfer, }) } @@ -353,7 +350,6 @@ func (h *Handler) GetKeyPermissions() http.HandlerFunc { response = append(response, KeyPermissionResponse{ InstanceID: perm.InstanceID, InstanceName: instanceNameMap[perm.InstanceID], - CanInfer: perm.CanInfer, }) } diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx index 26fe928..806060a 100644 --- a/webui/src/components/settings/ApiKeysSection.tsx +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -249,11 +249,7 @@ function ApiKeysSection() { {perm.instance_name} - {perm.can_infer ? ( - - ) : ( - - )} + ))} diff --git a/webui/src/types/apiKey.ts b/webui/src/types/apiKey.ts index 2f3d786..3dd68c0 100644 --- a/webui/src/types/apiKey.ts +++ b/webui/src/types/apiKey.ts @@ -23,7 +23,6 @@ export interface CreateKeyRequest { export interface InstancePermission { InstanceID: number - CanInfer: boolean } export interface CreateKeyResponse extends ApiKey { @@ -33,5 +32,4 @@ export interface CreateKeyResponse extends ApiKey { export interface KeyPermissionResponse { instance_id: number instance_name: string - can_infer: boolean } From fa311c46acc3f728d249ed6ee6bb1b49f84978a2 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 19:52:40 +0100 Subject: [PATCH 11/24] Improve server shutdown process --- .vscode/launch.json | 1 + cmd/server/main.go | 15 +++++++++++++-- pkg/database/database.go | 6 ++++++ pkg/manager/manager.go | 5 ----- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 12a8525..5422f27 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,6 +14,7 @@ "GO_ENV": "development", "LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml" }, + "console": "integratedTerminal", } ] } \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 9431b59..579b924 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "llamactl/pkg/config" "llamactl/pkg/database" @@ -11,6 +12,7 @@ import ( "os" "os/signal" "syscall" + "time" ) // version is set at build time using -ldflags "-X main.version=1.0.0" @@ -116,14 +118,23 @@ func main() { <-stop fmt.Println("Shutting down server...") - if err := server.Close(); err != nil { + // Create shutdown context with timeout + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + // Shutdown HTTP server gracefully + if err := server.Shutdown(shutdownCtx); err != nil { log.Printf("Error shutting down server: %v\n", err) } else { fmt.Println("Server shut down gracefully.") } - // Wait for all instances to stop + // Stop all instances and cleanup instanceManager.Shutdown() + if err := db.Close(); err != nil { + log.Printf("Error closing database: %v\n", err) + } + fmt.Println("Exiting llamactl.") } diff --git a/pkg/database/database.go b/pkg/database/database.go index 98793ff..b5ed946 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -107,6 +107,12 @@ func Open(config *Config) (*sqliteDB, error) { func (db *sqliteDB) Close() error { if db.DB != nil { log.Println("Closing database connection") + + // Checkpoint WAL to merge changes back to main database file + if _, err := db.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { + log.Printf("Warning: Failed to checkpoint WAL: %v", err) + } + return db.DB.Close() } return nil diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index fc23a2b..e566fe4 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -114,11 +114,6 @@ func (im *instanceManager) Shutdown() { } wg.Wait() fmt.Println("All instances stopped.") - - // 4. Close database connection - if err := im.db.Close(); err != nil { - log.Printf("Error closing database: %v\n", err) - } }) } From 0217f7cc4ea9b5fd2dbc6f040147c4a81e8e027d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 20:58:17 +0100 Subject: [PATCH 12/24] Fix instance creation to retrieve and set the auto-generated ID --- pkg/database/instances.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/database/instances.go b/pkg/database/instances.go index 1dbdb32..ab1ec4c 100644 --- a/pkg/database/instances.go +++ b/pkg/database/instances.go @@ -45,7 +45,7 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { ) VALUES (?, ?, ?, ?, ?, ?) ` - _, err = db.DB.ExecContext(ctx, query, + result, err := db.DB.ExecContext(ctx, query, row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID, ) @@ -53,6 +53,14 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error { return fmt.Errorf("failed to insert instance: %w", err) } + // Get the auto-generated ID and set it on the instance + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert ID: %w", err) + } + + inst.ID = int(id) + return nil } From 02193bd30903cb4474843b30bc9cf476fd5e8913 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 21:28:17 +0100 Subject: [PATCH 13/24] Add instance ID to JSON output --- pkg/database/instances.go | 1 + pkg/manager/manager.go | 1 + pkg/manager/operations.go | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/database/instances.go b/pkg/database/instances.go index ab1ec4c..3108e82 100644 --- a/pkg/database/instances.go +++ b/pkg/database/instances.go @@ -271,6 +271,7 @@ func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error) // Build complete instance JSON with all fields instanceJSON, err := json.Marshal(map[string]any{ + "id": row.ID, "name": row.Name, "created": row.CreatedAt, "status": row.Status, diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index e566fe4..47e554b 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -176,6 +176,7 @@ func (im *instanceManager) loadInstance(persistedInst *instance.Instance) error inst := instance.New(name, im.globalConfig, options, statusCallback) // Restore persisted fields that NewInstance doesn't set + inst.ID = persistedInst.ID inst.Created = persistedInst.Created inst.SetStatus(persistedInst.GetStatus()) diff --git a/pkg/manager/operations.go b/pkg/manager/operations.go index 7256d7f..2cfbebf 100644 --- a/pkg/manager/operations.go +++ b/pkg/manager/operations.go @@ -37,7 +37,6 @@ func (im *instanceManager) ListInstances() ([]*instance.Instance, error) { if node := im.getNodeForInstance(inst); node != nil { remoteInst, err := im.remote.getInstance(ctx, node, inst.Name) if err != nil { - // Log error but continue with stale data // Don't fail the entire list operation due to one remote failure continue } From 0fee7abc7c80a51f2bb17c472b2f0e31f0645f6a Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 22:20:05 +0100 Subject: [PATCH 14/24] Simplify create key request format --- pkg/server/handlers_auth.go | 29 ++++++++++++----------------- webui/src/types/apiKey.ts | 12 ++++-------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/pkg/server/handlers_auth.go b/pkg/server/handlers_auth.go index 7b77906..e2a4bf5 100644 --- a/pkg/server/handlers_auth.go +++ b/pkg/server/handlers_auth.go @@ -11,17 +11,12 @@ import ( "github.com/go-chi/chi/v5" ) -// InstancePermission defines the permissions for an API key on a specific instance. -type InstancePermission struct { - InstanceID int `json:"instance_id"` -} - // CreateKeyRequest represents the request body for creating a new API key. type CreateKeyRequest struct { - Name string - PermissionMode auth.PermissionMode - ExpiresAt *int64 - InstancePermissions []InstancePermission + Name string `json:"name"` + PermissionMode auth.PermissionMode `json:"permission_mode"` + ExpiresAt *int64 `json:"expires_at,omitempty"` + InstanceIDs []int `json:"instance_ids,omitempty"` } // CreateKeyResponse represents the response returned when creating a new API key. @@ -87,8 +82,8 @@ func (h *Handler) CreateKey() http.HandlerFunc { writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'") return } - if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstancePermissions) == 0 { - writeError(w, http.StatusBadRequest, "missing_permissions", "Instance permissions required when permission mode is 'per_instance'") + if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstanceIDs) == 0 { + writeError(w, http.StatusBadRequest, "missing_permissions", "Instance IDs required when permission mode is 'per_instance'") return } if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() { @@ -108,9 +103,9 @@ func (h *Handler) CreateKey() http.HandlerFunc { instanceIDMap[inst.ID] = true } - for _, perm := range req.InstancePermissions { - if !instanceIDMap[perm.InstanceID] { - writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", perm.InstanceID)) + for _, instanceID := range req.InstanceIDs { + if !instanceIDMap[instanceID] { + writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", instanceID)) return } } @@ -142,12 +137,12 @@ func (h *Handler) CreateKey() http.HandlerFunc { UpdatedAt: now, } - // Convert InstancePermissions to KeyPermissions + // Convert InstanceIDs to KeyPermissions var keyPermissions []auth.KeyPermission - for _, perm := range req.InstancePermissions { + for _, instanceID := range req.InstanceIDs { keyPermissions = append(keyPermissions, auth.KeyPermission{ KeyID: 0, // Will be set by database after key creation - InstanceID: perm.InstanceID, + InstanceID: instanceID, }) } diff --git a/webui/src/types/apiKey.ts b/webui/src/types/apiKey.ts index 3dd68c0..0889c9f 100644 --- a/webui/src/types/apiKey.ts +++ b/webui/src/types/apiKey.ts @@ -15,14 +15,10 @@ export interface ApiKey { } export interface CreateKeyRequest { - Name: string - PermissionMode: PermissionMode - ExpiresAt?: number - InstancePermissions: InstancePermission[] -} - -export interface InstancePermission { - InstanceID: number + name: string + permission_mode: PermissionMode + expires_at?: number + instance_ids: number[] } export interface CreateKeyResponse extends ApiKey { From cd1bd64889cc477b0e2687be5125708ffdcc3949 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 6 Dec 2025 22:20:39 +0100 Subject: [PATCH 15/24] Refactor CreateApiKeyDialog to use instance IDs --- .../components/apikeys/CreateApiKeyDialog.tsx | 70 ++++++++++--------- .../components/settings/ApiKeysSection.tsx | 35 ++++------ 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/webui/src/components/apikeys/CreateApiKeyDialog.tsx b/webui/src/components/apikeys/CreateApiKeyDialog.tsx index 5c59ba3..06e2d78 100644 --- a/webui/src/components/apikeys/CreateApiKeyDialog.tsx +++ b/webui/src/components/apikeys/CreateApiKeyDialog.tsx @@ -8,9 +8,9 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Loader2 } from "lucide-react"; import { apiKeysApi } from "@/lib/api"; -import { CreateKeyRequest, PermissionMode, InstancePermission } from "@/types/apiKey"; +import { PermissionMode, type CreateKeyRequest } from "@/types/apiKey"; import { useInstances } from "@/contexts/InstancesContext"; -import { format, addDays } from "date-fns"; +import { format } from "date-fns"; interface CreateApiKeyDialogProps { open: boolean; @@ -61,22 +61,19 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi } // Build request - const permissions: InstancePermission[] = []; + const instanceIds: number[] = []; if (permissionMode === PermissionMode.PerInstance) { - Object.entries(instancePermissions).forEach(([instanceId, canInfer]) => { - if (canInfer) { - permissions.push({ - InstanceID: parseInt(instanceId), - CanInfer: true, - }); + Object.entries(instancePermissions).forEach(([instanceId, hasPermission]) => { + if (hasPermission) { + instanceIds.push(parseInt(instanceId)); } }); } const request: CreateKeyRequest = { - Name: name.trim(), - PermissionMode: permissionMode, - InstancePermissions: permissions, + name: name.trim(), + permission_mode: permissionMode, + instance_ids: instanceIds, }; // Add expiration if provided @@ -87,7 +84,7 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi setError("Expiration date must be in the future"); return; } - request.ExpiresAt = Math.floor(expirationDate.getTime() / 1000); + request.expires_at = Math.floor(expirationDate.getTime() / 1000); } setLoading(true); @@ -107,10 +104,10 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi }; const handleInstancePermissionChange = (instanceId: number, checked: boolean) => { - setInstancePermissions({ - ...instancePermissions, + setInstancePermissions(prev => ({ + ...prev, [instanceId]: checked, - }); + })); }; return ( @@ -172,25 +169,30 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi

No instances available

) : (
- {instances.map((instance) => ( -
- - handleInstancePermissionChange(instance.id, checked as boolean) - } - disabled={loading} - /> -
+ ); + })}
)}
diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx index 806060a..b70683a 100644 --- a/webui/src/components/settings/ApiKeysSection.tsx +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, Fragment } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -175,9 +175,8 @@ function ApiKeysSection() { {keys.map((key) => ( - <> + handleRowClick(key)} > @@ -236,25 +235,15 @@ function ApiKeysSection() {

Loading permissions...

) : permissions[key.id] ? (
-

Instance Permissions:

- - - - - - - - - {permissions[key.id].map((perm) => ( - - - - - ))} - -
Instance NameCan Infer
{perm.instance_name} - -
+

Allowed Instances:

+
    + {permissions[key.id].map((perm) => ( +
  • + + {perm.instance_name} +
  • + ))} +
) : (

No permissions data

@@ -262,7 +251,7 @@ function ApiKeysSection() { )} - +
))} From 54fe0f7421c869cb8356859612578fbb46150dd5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 7 Dec 2025 16:16:13 +0100 Subject: [PATCH 16/24] Fix eslint issues --- .../src/components/apikeys/CreateApiKeyDialog.tsx | 2 +- webui/src/components/settings/ApiKeysSection.tsx | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/webui/src/components/apikeys/CreateApiKeyDialog.tsx b/webui/src/components/apikeys/CreateApiKeyDialog.tsx index 06e2d78..ed5c463 100644 --- a/webui/src/components/apikeys/CreateApiKeyDialog.tsx +++ b/webui/src/components/apikeys/CreateApiKeyDialog.tsx @@ -116,7 +116,7 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi Create API Key -
+ void handleSubmit(e)} className="space-y-4"> {error && ( {error} diff --git a/webui/src/components/settings/ApiKeysSection.tsx b/webui/src/components/settings/ApiKeysSection.tsx index b70683a..2ca9633 100644 --- a/webui/src/components/settings/ApiKeysSection.tsx +++ b/webui/src/components/settings/ApiKeysSection.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react"; import { apiKeysApi } from "@/lib/api"; -import { ApiKey, KeyPermissionResponse, PermissionMode } from "@/types/apiKey"; +import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey"; import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog"; import { format, formatDistanceToNow } from "date-fns"; @@ -20,7 +20,7 @@ function ApiKeysSection() { const [loadingPermissions, setLoadingPermissions] = useState>({}); useEffect(() => { - fetchKeys(); + void fetchKeys(); }, []); const fetchKeys = async () => { @@ -52,7 +52,7 @@ function ApiKeysSection() { const handleKeyCreated = (plainTextKey: string) => { setNewKeyPlainText(plainTextKey); - fetchKeys(); + void fetchKeys(); setCreateDialogOpen(false); }; @@ -75,7 +75,7 @@ function ApiKeysSection() { try { await apiKeysApi.delete(id); - fetchKeys(); + void fetchKeys(); } catch (err) { alert(err instanceof Error ? err.message : "Failed to delete API key"); } @@ -87,7 +87,7 @@ function ApiKeysSection() { } else { setExpandedRowId(key.id); if (key.permission_mode === PermissionMode.PerInstance) { - fetchPermissions(key.id); + void fetchPermissions(key.id); } } }; @@ -136,7 +136,7 @@ function ApiKeysSection() { {newKeyPlainText} -
@@ -216,7 +216,7 @@ function ApiKeysSection() { size="icon" onClick={(e) => { e.stopPropagation(); - handleDeleteKey(key.id, key.name); + void handleDeleteKey(key.id, key.name); }} title="Delete key" > From 00a502a2689a7dd4fd701d40372eb1c43a857829 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 7 Dec 2025 17:16:40 +0100 Subject: [PATCH 17/24] Implement LocalStorageMock for testing --- webui/src/test/setup.ts | 44 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/webui/src/test/setup.ts b/webui/src/test/setup.ts index 0e7e331..59747be 100644 --- a/webui/src/test/setup.ts +++ b/webui/src/test/setup.ts @@ -1,10 +1,44 @@ import '@testing-library/jest-dom' -import { afterEach, vi } from 'vitest' +import { afterEach, beforeEach } from 'vitest' -// Mock fetch globally since your app uses fetch -global.fetch = vi.fn() +// Create a working localStorage implementation for tests +// This ensures localStorage works in both CLI and VSCode test runner +class LocalStorageMock implements Storage { + private store: Map = new Map() + + get length(): number { + return this.store.size + } + + clear(): void { + this.store.clear() + } + + getItem(key: string): string | null { + return this.store.get(key) ?? null + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null + } + + removeItem(key: string): void { + this.store.delete(key) + } + + setItem(key: string, value: string): void { + this.store.set(key, value) + } +} + +// Replace global localStorage +global.localStorage = new LocalStorageMock() + +// Clean up before each test +beforeEach(() => { + localStorage.clear() +}) -// Clean up after each test afterEach(() => { - vi.clearAllMocks() + localStorage.clear() }) \ No newline at end of file From 1acbcafe1c05a9cb23016cb3e0c3035baf514564 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 7 Dec 2025 17:26:38 +0100 Subject: [PATCH 18/24] Add DialogDescription to SettingsDialog --- webui/src/components/settings/SettingsDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webui/src/components/settings/SettingsDialog.tsx b/webui/src/components/settings/SettingsDialog.tsx index 9bd2e18..0f00aef 100644 --- a/webui/src/components/settings/SettingsDialog.tsx +++ b/webui/src/components/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import ApiKeysSection from "./ApiKeysSection"; interface SettingsDialogProps { @@ -12,6 +12,9 @@ function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { Settings + + Manage your application settings and API keys. + From 0ce9016488c8e9645e57224a05398d9e35565fc0 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 7 Dec 2025 17:40:09 +0100 Subject: [PATCH 19/24] Fix some lint issues --- .../__tests__/InstanceList.test.tsx | 3 +- webui/src/components/form/KeyValueInput.tsx | 2 +- .../__tests__/InstancesContext.test.tsx | 3 +- webui/src/lib/healthService.ts | 39 +++++++++++-------- webui/src/main.tsx | 5 ++- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index 01d8c25..a6924b7 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -4,8 +4,7 @@ import userEvent from '@testing-library/user-event' import InstanceList from '@/components/InstanceList' import { InstancesProvider } from '@/contexts/InstancesContext' import { instancesApi } from '@/lib/api' -import type { Instance } from '@/types/instance' -import { BackendType } from '@/types/instance' +import { BackendType, type Instance } from '@/types/instance' import { AuthProvider } from '@/contexts/AuthContext' // Mock the API diff --git a/webui/src/components/form/KeyValueInput.tsx b/webui/src/components/form/KeyValueInput.tsx index 62585c4..f136ecd 100644 --- a/webui/src/components/form/KeyValueInput.tsx +++ b/webui/src/components/form/KeyValueInput.tsx @@ -59,7 +59,7 @@ const KeyValueInput: React.FC = ({ // Reset to single empty row if value is explicitly undefined/null setPairs([{ key: '', value: '' }]) } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) // Update parent component when pairs change diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index cad603d..1f99055 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -3,8 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; import { instancesApi } from "@/lib/api"; -import type { Instance } from "@/types/instance"; -import { BackendType } from "@/types/instance"; +import { BackendType, type Instance } from "@/types/instance"; import { AuthProvider } from "../AuthContext"; // Mock the API module diff --git a/webui/src/lib/healthService.ts b/webui/src/lib/healthService.ts index 7d47486..46cfcdf 100644 --- a/webui/src/lib/healthService.ts +++ b/webui/src/lib/healthService.ts @@ -156,11 +156,14 @@ class HealthService { this.callbacks.set(instanceName, new Set()) } - this.callbacks.get(instanceName)!.add(callback) + const callbacks = this.callbacks.get(instanceName) + if (callbacks) { + callbacks.add(callback) - // Start health checking if this is the first subscriber - if (this.callbacks.get(instanceName)!.size === 1) { - this.startHealthCheck(instanceName) + // Start health checking if this is the first subscriber + if (callbacks.size === 1) { + this.startHealthCheck(instanceName) + } } // Return unsubscribe function @@ -214,22 +217,24 @@ class HealthService { } // Start new interval with appropriate timing - const interval = setInterval(async () => { - try { - const health = await this.performHealthCheck(instanceName) - this.notifyCallbacks(instanceName, health) + const interval = setInterval(() => { + void (async () => { + try { + const health = await this.performHealthCheck(instanceName) + this.notifyCallbacks(instanceName, health) - // Check if state changed and adjust interval - const previousState = this.lastHealthState.get(instanceName) - this.lastHealthState.set(instanceName, health.state) + // Check if state changed and adjust interval + const previousState = this.lastHealthState.get(instanceName) + this.lastHealthState.set(instanceName, health.state) - if (previousState !== health.state) { - this.adjustPollingInterval(instanceName, health.state) + if (previousState !== health.state) { + this.adjustPollingInterval(instanceName, health.state) + } + } catch (error) { + console.error(`Health check failed for ${instanceName}:`, error) + // Continue polling even on error } - } catch (error) { - console.error(`Health check failed for ${instanceName}:`, error) - // Continue polling even on error - } + })() }, pollInterval) this.intervals.set(instanceName, interval) diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 6418a1e..085ab4f 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -6,7 +6,10 @@ import './index.css' import { AuthProvider } from './contexts/AuthContext' import { ConfigProvider } from './contexts/ConfigContext' -ReactDOM.createRoot(document.getElementById('root')!).render( +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +ReactDOM.createRoot(rootElement).render( From 4b1b12a7a8afceca19f225152a2b0edd01c6261d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 7 Dec 2025 18:28:01 +0100 Subject: [PATCH 20/24] Fix lint errors --- webui/package-lock.json | 12 ------------ webui/package.json | 1 - .../components/instance/AutoRestartConfiguration.tsx | 2 +- .../src/components/instance/BackendConfiguration.tsx | 10 ++++++---- .../src/contexts/__tests__/InstancesContext.test.tsx | 12 ++++++------ webui/tsconfig.node.json | 3 ++- 6 files changed, 15 insertions(+), 25 deletions(-) diff --git a/webui/package-lock.json b/webui/package-lock.json index cd6b0ea..7e5610c 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -31,7 +31,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/eslint__js": "^9.14.0", "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", @@ -2557,17 +2556,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint__js": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-9.14.0.tgz", - "integrity": "sha512-s0jepCjOJWB/GKcuba4jISaVpBudw3ClXJ3fUK4tugChUMQsp6kSwuA8Dcx6wFd/JsJqcY8n4rEpa5RTHs5ypA==", - "deprecated": "This is a stub types definition. @eslint/js provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint/js": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/webui/package.json b/webui/package.json index a66e024..ca4fa6f 100644 --- a/webui/package.json +++ b/webui/package.json @@ -40,7 +40,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@types/eslint__js": "^9.14.0", "@types/node": "^24.10.1", "@types/react": "^19.2.4", "@types/react-dom": "^19.2.3", diff --git a/webui/src/components/instance/AutoRestartConfiguration.tsx b/webui/src/components/instance/AutoRestartConfiguration.tsx index fe3a900..bb40b7d 100644 --- a/webui/src/components/instance/AutoRestartConfiguration.tsx +++ b/webui/src/components/instance/AutoRestartConfiguration.tsx @@ -5,7 +5,7 @@ import NumberInput from '@/components/form/NumberInput' interface AutoRestartConfigurationProps { formData: CreateInstanceOptions - onChange: (key: keyof CreateInstanceOptions, value: any) => void + onChange: (key: K, value: CreateInstanceOptions[K]) => void } const AutoRestartConfiguration: React.FC = ({ diff --git a/webui/src/components/instance/BackendConfiguration.tsx b/webui/src/components/instance/BackendConfiguration.tsx index 8f10e41..064bbcb 100644 --- a/webui/src/components/instance/BackendConfiguration.tsx +++ b/webui/src/components/instance/BackendConfiguration.tsx @@ -3,9 +3,11 @@ import type { CreateInstanceOptions } from '@/types/instance' import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils' import BackendFormField from '@/components/BackendFormField' +type BackendFieldValue = string | number | boolean | string[] | Record | undefined + interface BackendConfigurationProps { formData: CreateInstanceOptions - onBackendFieldChange: (key: string, value: any) => void + onBackendFieldChange: (key: string, value: BackendFieldValue) => void showAdvanced?: boolean } @@ -26,7 +28,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.[fieldKey]} onChange={onBackendFieldChange} /> ))} @@ -41,7 +43,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.[fieldKey]} onChange={onBackendFieldChange} /> ))} @@ -53,7 +55,7 @@ const BackendConfiguration: React.FC = ({ | undefined)?.extra_args} onChange={onBackendFieldChange} />
diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index 1f99055..f35c49a 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -70,37 +70,37 @@ function TestComponent() { {/* Action buttons for testing with specific instances */}