Compare commits

...

2 Commits

3 changed files with 176 additions and 50 deletions

View File

@@ -3,11 +3,13 @@ package work.slhaf.hub
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import io.ktor.server.request.receiveText import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.Routing
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post 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") 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) { fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing { routing {
get("/health") { get("/health") {
call.respondText("OK") call.respondText("OK")
} }
get("/type") { registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
val auth = requireAuth(call, security) ?: return@get registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
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)
}
get("/scripts/{script}") { get("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@get val auth = requireAuth(call, security) ?: return@get
@@ -131,46 +229,6 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
handleDeleteScript(call, scriptsDir) 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") { get("/subtokens") {
val auth = requireAuth(call, security) ?: return@get val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get if (!requireRoot(call, auth)) return@get
@@ -211,17 +269,22 @@ Usage:
Routes: Routes:
GET /health GET /health
GET /type GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization: Authorization:
Authorization: Bearer <token> Authorization: Bearer <token>
or X-Host-Token: <token> or X-Host-Token: <token>
GET /scripts GET /scripts
GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth)
GET /scripts/{script} (root only) GET /scripts/{script} (root only)
POST /scripts/{script} (root only) POST /scripts/{script} (root only)
PUT /scripts/{script} (root only) PUT /scripts/{script} (root only)
DELETE /scripts/{script} (root only) DELETE /scripts/{script} (root only)
GET /meta/{script} (root or allowed subtoken) GET /meta/{script} (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/meta/{script}
GET /run/{script}?k=v (root or allowed subtoken) 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 /run/{script}?k=v (root or allowed subtoken)
POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v
GET /subtokens (root only) GET /subtokens (root only)
GET /subtokens/{name} (root only) GET /subtokens/{name} (root only)
POST /subtokens/{name} (root only, body: script names list) POST /subtokens/{name} (root only, body: script names list)

View File

@@ -62,6 +62,13 @@ class SubTokenStore(
byName.values.firstOrNull { it.token == token } 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 { fun create(name: String, scripts: Set<String>): SubTokenRecord {
synchronized(lock) { synchronized(lock) {
ensureLoaded() ensureLoaded()
@@ -198,6 +205,23 @@ private fun authenticateToken(call: ApplicationCall, security: HostSecurity): Au
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts) 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? { suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
val context = authenticateToken(call, security) val context = authenticateToken(call, security)
if (context != null) return context if (context != null) return context
@@ -207,6 +231,21 @@ suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthCont
return null 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 { suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
if (context.type == TokenType.ROOT) return true if (context.type == TokenType.ROOT) return true
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain) call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)

View File

@@ -159,6 +159,30 @@ class WebHostApiTest {
val listSubTokens = client.get("/subtokens") { bearer(token) } val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status) 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 @Test