feat: add token-aware API TUI with subtoken actions and row-based focus navigation

This commit is contained in:
2026-02-24 21:14:53 +08:00
parent 3e23adf821
commit 2796638311
7 changed files with 991 additions and 322 deletions

View File

@@ -6,6 +6,7 @@ import io.ktor.server.application.Application
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.delete
import io.ktor.server.routing.get
@@ -18,35 +19,122 @@ private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0"
private fun Application.module(scriptsDir: File, apiToken: String) {
private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
}
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val created =
try {
security.subTokens.create(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
}
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
}
private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val scriptsRaw = call.receiveText()
val scripts =
try {
parseScriptNameSet(scriptsRaw)
} catch (t: Throwable) {
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
}
val updated =
try {
security.subTokens.update(name, scripts)
} catch (t: Throwable) {
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
}
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val item = security.subTokens.get(name)
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
}
private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
val deleted = security.subTokens.delete(name)
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
call.respondText("deleted subtoken: $name")
}
private fun Application.module(scriptsDir: File, security: HostSecurity) {
routing {
get("/health") {
call.respondText("OK")
}
get("/scripts") {
if (!requireAuth(call, apiToken)) return@get
call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain)
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)
}
get("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@get
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
handleGetScriptContent(call, scriptsDir)
}
post("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@post
val auth = requireAuth(call, security) ?: return@post
if (!requireRoot(call, auth)) return@post
handleCreateScript(call, scriptsDir)
}
put("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@put
val auth = requireAuth(call, security) ?: return@put
if (!requireRoot(call, auth)) return@put
handleUpdateScript(call, scriptsDir)
}
delete("/scripts/{script}") {
if (!requireAuth(call, apiToken)) return@delete
val auth = requireAuth(call, security) ?: return@delete
if (!requireRoot(call, auth)) return@delete
handleDeleteScript(call, scriptsDir)
}
get("/meta/{script}") {
if (!requireAuth(call, apiToken)) return@get
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()) {
@@ -56,17 +144,55 @@ private fun Application.module(scriptsDir: File, apiToken: String) {
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json
contentType = ContentType.Application.Json,
)
}
get("/run/{script}") {
if (!requireAuth(call, apiToken)) return@get
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
handleRunRequest(call, scriptsDir, consumeBody = false)
}
post("/run/{script}") {
if (!requireAuth(call, apiToken)) return@post
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
handleRunRequest(call, scriptsDir, consumeBody = true)
}
get("/subtokens") {
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
}
get("/subtokens/{name}") {
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
handleSubTokenGet(call, security)
}
post("/subtokens/{name}") {
val auth = requireAuth(call, security) ?: return@post
if (!requireRoot(call, auth)) return@post
handleSubTokenCreate(call, security)
}
put("/subtokens/{name}") {
val auth = requireAuth(call, security) ?: return@put
if (!requireRoot(call, auth)) return@put
handleSubTokenUpdate(call, security)
}
delete("/subtokens/{name}") {
val auth = requireAuth(call, security) ?: return@delete
if (!requireRoot(call, auth)) return@delete
handleSubTokenDelete(call, security)
}
}
}
@@ -77,18 +203,24 @@ Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts]'
Routes:
GET /health
GET /type
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /scripts/{script}
POST /scripts/{script}
PUT /scripts/{script}
DELETE /scripts/{script}
GET /meta/{script}
GET /run/{script}?k=v
POST /run/{script}?k=v
""".trimIndent()
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 /run/{script}?k=v (root or allowed subtoken)
POST /run/{script}?k=v (root or allowed subtoken)
GET /subtokens (root only)
GET /subtokens/{name} (root only)
POST /subtokens/{name} (root only, body: script names list)
PUT /subtokens/{name} (root only, body: script names list)
DELETE /subtokens/{name} (root only)
""".trimIndent(),
)
}
@@ -109,6 +241,7 @@ fun main(args: Array<String>) {
if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token)
println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
@@ -117,10 +250,11 @@ fun main(args: Array<String>) {
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
auth.source.startsWith("generated:") ->
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
}
embeddedServer(Netty, port = port, host = host) {
module(scriptsDir, auth.token)
module(scriptsDir, security)
}.start(wait = true)
}

View File

@@ -29,8 +29,11 @@ private fun listScriptNames(scriptsDir: File): List<String> =
?.toList()
?: emptyList()
fun renderScriptList(scriptsDir: File): String =
listScriptNames(scriptsDir).joinToString("\n") { name ->
fun renderScriptList(scriptsDir: File, allowNames: Set<String>? = null): String =
listScriptNames(scriptsDir)
.asSequence()
.filter { allowNames == null || allowNames.contains(it) }
.joinToString("\n") { name ->
val file = resolveScriptFile(scriptsDir, name)
val description = file?.let(::cachedMetadata)?.description
if (description.isNullOrBlank()) name else "$name\t$description"

View File

@@ -10,15 +10,151 @@ import java.security.SecureRandom
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
private const val TOKEN_FILE_NAME = ".host-api-token"
private const val SUBTOKEN_FILE_NAME = ".host-subtokens.db"
private const val ALT_TOKEN_HEADER = "X-Host-Token"
private val SCRIPT_NAME_REGEX = Regex("[A-Za-z0-9._-]+")
data class ApiTokenConfig(
val token: String,
val source: String,
val tokenFile: File?
val tokenFile: File?,
)
private fun randomTokenHex(bytes: Int = 32): String {
enum class TokenType {
ROOT,
SUB,
}
data class SubTokenRecord(
val name: String,
val token: String,
val scripts: Set<String>,
)
data class AuthContext(
val type: TokenType,
val subTokenName: String? = null,
val allowedScripts: Set<String> = emptySet(),
)
class SubTokenStore(
private val storageFile: File,
) {
private val lock = Any()
private var loaded = false
private val byName = linkedMapOf<String, SubTokenRecord>()
fun list(): List<SubTokenRecord> =
synchronized(lock) {
ensureLoaded()
byName.values.sortedBy { it.name }
}
fun get(name: String): SubTokenRecord? =
synchronized(lock) {
ensureLoaded()
byName[name]
}
fun findByToken(token: String): SubTokenRecord? =
synchronized(lock) {
ensureLoaded()
byName.values.firstOrNull { it.token == token }
}
fun create(name: String, scripts: Set<String>): SubTokenRecord {
synchronized(lock) {
ensureLoaded()
require(name.matches(SCRIPT_NAME_REGEX)) { "invalid subtoken name" }
if (byName.containsKey(name)) error("subtoken already exists: $name")
val record = SubTokenRecord(name = name, token = randomTokenHex(), scripts = scripts.sorted().toSet())
byName[name] = record
persist()
return record
}
}
fun update(name: String, scripts: Set<String>): SubTokenRecord {
synchronized(lock) {
ensureLoaded()
val existing = byName[name] ?: error("subtoken not found: $name")
val updated = existing.copy(scripts = scripts.sorted().toSet())
byName[name] = updated
persist()
return updated
}
}
fun delete(name: String): Boolean =
synchronized(lock) {
ensureLoaded()
val removed = byName.remove(name) ?: return false
persist()
removed.name.isNotBlank()
}
private fun ensureLoaded() {
if (loaded) return
loaded = true
if (!storageFile.exists()) return
storageFile.readLines().forEach { line ->
val trimmed = line.trim()
if (trimmed.isBlank() || trimmed.startsWith("#")) return@forEach
val parts = trimmed.split('\t')
if (parts.size < 2) return@forEach
val name = parts[0].trim()
val token = parts[1].trim()
if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return@forEach
val scripts =
parts.getOrNull(2)
?.split(',')
?.map { it.trim() }
?.filter { it.isNotBlank() && it.matches(SCRIPT_NAME_REGEX) }
?.toSet()
?: emptySet()
byName[name] = SubTokenRecord(name, token, scripts)
}
}
private fun persist() {
storageFile.parentFile?.mkdirs()
val text =
buildString {
appendLine("# name\\ttoken\\tscript1,script2,...")
byName.values.sortedBy { it.name }.forEach { record ->
val scripts = record.scripts.sorted().joinToString(",")
append(record.name)
append('\t')
append(record.token)
append('\t')
append(scripts)
appendLine()
}
}
storageFile.writeText(text)
storageFile.setReadable(false, false)
storageFile.setReadable(true, true)
storageFile.setWritable(false, false)
storageFile.setWritable(true, true)
}
}
data class HostSecurity(
val rootToken: String,
val subTokens: SubTokenStore,
)
fun createHostSecurity(scriptsDir: File, rootToken: String): HostSecurity =
HostSecurity(rootToken = rootToken, subTokens = SubTokenStore(File(scriptsDir, SUBTOKEN_FILE_NAME)))
fun randomTokenHex(bytes: Int = 32): String {
val random = ByteArray(bytes)
SecureRandom().nextBytes(random)
return random.joinToString("") { "%02x".format(it) }
@@ -53,11 +189,86 @@ private fun extractProvidedToken(call: ApplicationCall): String? {
return call.request.headers[ALT_TOKEN_HEADER]?.trim()
}
suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean {
private fun authenticateToken(call: ApplicationCall, security: HostSecurity): AuthContext? {
val provided = extractProvidedToken(call)
if (provided == expectedToken) return true
if (provided.isNullOrBlank()) return null
if (provided == security.rootToken) return AuthContext(type = TokenType.ROOT)
val sub = security.subTokens.findByToken(provided) ?: 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
call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"")
call.respondText("unauthorized", 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)
return false
}
suspend fun requireScriptAccess(call: ApplicationCall, context: AuthContext, scriptName: String): Boolean {
if (context.type == TokenType.ROOT) return true
if (context.allowedScripts.contains(scriptName)) return true
call.respondText("forbidden: subtoken has no access to script '$scriptName'", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
return false
}
fun visibleScriptsFor(context: AuthContext): Set<String>? =
when (context.type) {
TokenType.ROOT -> null
TokenType.SUB -> context.allowedScripts
}
private fun String.jsonEscaped(): String = buildString(length) {
for (ch in this@jsonEscaped) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
}
fun tokenTypeJson(context: AuthContext): String {
val scripts = context.allowedScripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
val name = context.subTokenName?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
return """{"tokenType":"${context.type.name.lowercase()}","subTokenName":$name,"scripts":[${scripts}]}"""
}
fun parseScriptNameSet(raw: String): Set<String> {
val values =
raw.split(Regex("[,\\n\\r\\t\\s]+"))
.map { it.trim() }
.filter { it.isNotBlank() }
.toSet()
require(values.all { it.matches(SCRIPT_NAME_REGEX) }) {
"invalid script names, only [A-Za-z0-9._-] is allowed"
}
return values
}
fun subTokenListJson(records: List<SubTokenRecord>): String {
val items =
records.joinToString(",") { record ->
val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
"""{"name":"${record.name.jsonEscaped()}","token":"${record.token.jsonEscaped()}","scripts":[${scripts}]}"""
}
return """{"items":[${items}]}"""
}
fun subTokenItemJson(record: SubTokenRecord, includeToken: Boolean): String {
val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
val tokenPart = if (includeToken) "\"${record.token.jsonEscaped()}\"" else "null"
return """{"name":"${record.name.jsonEscaped()}","token":$tokenPart,"scripts":[${scripts}]}"""
}