Compare commits
2 Commits
07d5c1db52
...
ff012c3b9a
| Author | SHA1 | Date | |
|---|---|---|---|
| ff012c3b9a | |||
| e2fdb99c91 |
@@ -3,11 +3,13 @@ package work.slhaf.hub
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.call
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
@@ -90,22 +92,118 @@ private suspend fun handleSubTokenDelete(call: io.ktor.server.application.Applic
|
||||
call.respondText("deleted subtoken: $name")
|
||||
}
|
||||
|
||||
private suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
|
||||
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
|
||||
val allow = visibleScriptsFor(auth)
|
||||
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
|
||||
}
|
||||
|
||||
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
|
||||
val name = call.parameters["script"]
|
||||
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||
if (!requireScriptAccess(call, auth, name)) return
|
||||
|
||||
val script = resolveScriptFile(scriptsDir, name)
|
||||
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
||||
if (!script.exists()) {
|
||||
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
val (metadata, source) = loadMetadata(script)
|
||||
call.respondText(
|
||||
metadataJson(name, metadata, source),
|
||||
contentType = ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleRunForAuth(
|
||||
call: ApplicationCall,
|
||||
scriptsDir: File,
|
||||
auth: AuthContext,
|
||||
runConcurrencyLimiter: Semaphore,
|
||||
consumeBody: Boolean,
|
||||
) {
|
||||
val name = call.parameters["script"]
|
||||
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||
if (!requireScriptAccess(call, auth, name)) return
|
||||
runConcurrencyLimiter.withPermit {
|
||||
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Routing.registerHeaderAuthenticatedRoutes(
|
||||
scriptsDir: File,
|
||||
security: HostSecurity,
|
||||
runConcurrencyLimiter: Semaphore,
|
||||
) {
|
||||
get("/type") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
handleTypeForAuth(call, auth)
|
||||
}
|
||||
|
||||
get("/scripts") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
handleScriptsForAuth(call, scriptsDir, auth)
|
||||
}
|
||||
|
||||
get("/meta/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
handleMetaForAuth(call, scriptsDir, auth)
|
||||
}
|
||||
|
||||
get("/run/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
|
||||
}
|
||||
|
||||
post("/run/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@post
|
||||
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Routing.registerSubTokenPathRoutes(
|
||||
scriptsDir: File,
|
||||
security: HostSecurity,
|
||||
runConcurrencyLimiter: Semaphore,
|
||||
) {
|
||||
get("/u/{subAuth}/type") {
|
||||
val auth = requireSubTokenPathAuth(call, security) ?: return@get
|
||||
handleTypeForAuth(call, auth)
|
||||
}
|
||||
|
||||
get("/u/{subAuth}/scripts") {
|
||||
val auth = requireSubTokenPathAuth(call, security) ?: return@get
|
||||
handleScriptsForAuth(call, scriptsDir, auth)
|
||||
}
|
||||
|
||||
get("/u/{subAuth}/meta/{script}") {
|
||||
val auth = requireSubTokenPathAuth(call, security) ?: return@get
|
||||
handleMetaForAuth(call, scriptsDir, auth)
|
||||
}
|
||||
|
||||
get("/u/{subAuth}/run/{script}") {
|
||||
val auth = requireSubTokenPathAuth(call, security) ?: return@get
|
||||
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
|
||||
}
|
||||
|
||||
post("/u/{subAuth}/run/{script}") {
|
||||
val auth = requireSubTokenPathAuth(call, security) ?: return@post
|
||||
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
|
||||
routing {
|
||||
get("/health") {
|
||||
call.respondText("OK")
|
||||
}
|
||||
|
||||
get("/type") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
get("/scripts") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
val allow = visibleScriptsFor(auth)
|
||||
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
|
||||
}
|
||||
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
|
||||
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
|
||||
|
||||
get("/scripts/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
@@ -131,46 +229,6 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
|
||||
handleDeleteScript(call, scriptsDir)
|
||||
}
|
||||
|
||||
get("/meta/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
val name = call.parameters["script"]
|
||||
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||
|
||||
if (!requireScriptAccess(call, auth, name)) return@get
|
||||
|
||||
val script = resolveScriptFile(scriptsDir, name)
|
||||
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
||||
if (!script.exists()) {
|
||||
return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
val (metadata, source) = loadMetadata(script)
|
||||
call.respondText(
|
||||
metadataJson(name, metadata, source),
|
||||
contentType = ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
|
||||
get("/run/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
val name = call.parameters["script"]
|
||||
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||
if (!requireScriptAccess(call, auth, name)) return@get
|
||||
runConcurrencyLimiter.withPermit {
|
||||
handleRunRequest(call, scriptsDir, consumeBody = false)
|
||||
}
|
||||
}
|
||||
|
||||
post("/run/{script}") {
|
||||
val auth = requireAuth(call, security) ?: return@post
|
||||
val name = call.parameters["script"]
|
||||
?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||
if (!requireScriptAccess(call, auth, name)) return@post
|
||||
runConcurrencyLimiter.withPermit {
|
||||
handleRunRequest(call, scriptsDir, consumeBody = true)
|
||||
}
|
||||
}
|
||||
|
||||
get("/subtokens") {
|
||||
val auth = requireAuth(call, security) ?: return@get
|
||||
if (!requireRoot(call, auth)) return@get
|
||||
@@ -211,17 +269,22 @@ Usage:
|
||||
Routes:
|
||||
GET /health
|
||||
GET /type
|
||||
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
|
||||
Authorization:
|
||||
Authorization: Bearer <token>
|
||||
or X-Host-Token: <token>
|
||||
GET /scripts
|
||||
GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth)
|
||||
GET /scripts/{script} (root only)
|
||||
POST /scripts/{script} (root only)
|
||||
PUT /scripts/{script} (root only)
|
||||
DELETE /scripts/{script} (root only)
|
||||
GET /meta/{script} (root or allowed subtoken)
|
||||
GET /u/{subtoken_name}@{subtoken}/meta/{script}
|
||||
GET /run/{script}?k=v (root or allowed subtoken)
|
||||
GET /u/{subtoken_name}@{subtoken}/run/{script}?k=v
|
||||
POST /run/{script}?k=v (root or allowed subtoken)
|
||||
POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v
|
||||
GET /subtokens (root only)
|
||||
GET /subtokens/{name} (root only)
|
||||
POST /subtokens/{name} (root only, body: script names list)
|
||||
|
||||
@@ -62,6 +62,13 @@ class SubTokenStore(
|
||||
byName.values.firstOrNull { it.token == token }
|
||||
}
|
||||
|
||||
fun findByNameAndToken(name: String, token: String): SubTokenRecord? =
|
||||
synchronized(lock) {
|
||||
ensureLoaded()
|
||||
val record = byName[name] ?: return null
|
||||
if (record.token == token) record else null
|
||||
}
|
||||
|
||||
fun create(name: String, scripts: Set<String>): SubTokenRecord {
|
||||
synchronized(lock) {
|
||||
ensureLoaded()
|
||||
@@ -198,6 +205,23 @@ private fun authenticateToken(call: ApplicationCall, security: HostSecurity): Au
|
||||
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
||||
}
|
||||
|
||||
private fun parseSubTokenPathCredential(raw: String?): Pair<String, String>? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isBlank()) return null
|
||||
val at = value.indexOf('@')
|
||||
if (at <= 0 || at == value.lastIndex) return null
|
||||
val name = value.substring(0, at).trim()
|
||||
val token = value.substring(at + 1).trim()
|
||||
if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return null
|
||||
return name to token
|
||||
}
|
||||
|
||||
fun authenticateSubTokenPath(pathCredential: String?, security: HostSecurity): AuthContext? {
|
||||
val (name, token) = parseSubTokenPathCredential(pathCredential) ?: return null
|
||||
val sub = security.subTokens.findByNameAndToken(name, token) ?: return null
|
||||
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
||||
}
|
||||
|
||||
suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
|
||||
val context = authenticateToken(call, security)
|
||||
if (context != null) return context
|
||||
@@ -207,6 +231,21 @@ suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthCont
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun requireSubTokenPathAuth(
|
||||
call: ApplicationCall,
|
||||
security: HostSecurity,
|
||||
pathParamName: String = "subAuth",
|
||||
): AuthContext? {
|
||||
val context = authenticateSubTokenPath(call.parameters[pathParamName], security)
|
||||
if (context != null) return context
|
||||
call.respondText(
|
||||
"unauthorized subtoken path, expected /u/<subtoken_name>@<subtoken>/...",
|
||||
status = HttpStatusCode.Unauthorized,
|
||||
contentType = ContentType.Text.Plain,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
|
||||
if (context.type == TokenType.ROOT) return true
|
||||
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
|
||||
|
||||
@@ -159,6 +159,30 @@ class WebHostApiTest {
|
||||
|
||||
val listSubTokens = client.get("/subtokens") { bearer(token) }
|
||||
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
|
||||
|
||||
val typeByPath = client.get("/u/demo-sub@$token/type")
|
||||
assertEquals(HttpStatusCode.OK, typeByPath.status)
|
||||
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
|
||||
|
||||
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
|
||||
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
|
||||
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
|
||||
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
|
||||
|
||||
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
|
||||
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
|
||||
|
||||
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
|
||||
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
|
||||
|
||||
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
|
||||
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
|
||||
|
||||
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
|
||||
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
|
||||
|
||||
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
|
||||
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user