working http server

This commit is contained in:
00asdf 2025-04-21 20:44:52 +02:00
parent 754275d7b2
commit abafed20e1
8 changed files with 356 additions and 2 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ bin/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
/.idea/misc.xml /.idea/misc.xml
/userlist.txt

View File

@ -0,0 +1,10 @@
package dev.asdf00.visionfive.hc
import java.util.*
import java.util.concurrent.CopyOnWriteArraySet
object GlobalDataStore {
@Volatile var temperature = 0.0
@Volatile var tempUpdated = Date()
val allowedTempUpdates = CopyOnWriteArraySet<String>()
}

View File

@ -1,4 +1,4 @@
package dev.asdf00.visionfive.hc package dev.asdf00.visionfive.hc.gpio
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path

View File

@ -1,4 +1,4 @@
package dev.asdf00.visionfive.hc package dev.asdf00.visionfive.hc.gpio
import kotlin.math.abs import kotlin.math.abs

View File

@ -0,0 +1,74 @@
package dev.asdf00.visionfive.hc.server
import java.nio.file.Files
import java.security.SecureRandom
import java.util.*
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import kotlin.io.path.Path
data class PwdHash(val hash: String, val salt: String)
object LoginBehavior {
object Users {
private val FILE_PATH get() = Path("userlist.txt")
private val users = loadFromFile()
operator fun get(userName: String) = users[userName]
operator fun set(userName: String, password: String) {
val salt = getRandomBase64(16)
users[userName] = PwdHash(hashPassword(password, salt), salt)
Files.writeString(FILE_PATH, users.map {
"${it.key};${it.value.hash};${it.value.salt}"
}.joinToString("\n"))
}
private fun loadFromFile(): MutableMap<String, PwdHash> {
if (Files.exists(FILE_PATH)) {
return Files.readString(FILE_PATH)
.split("\n")
.map { it.split(";") }
.filter { it.size == 3 }
.associateBy({ it[0] }, { PwdHash(it[1], it[2]) })
.toMutableMap()
} else {
Files.writeString(FILE_PATH, "")
return mutableMapOf()
}
}
fun remove(userName: String) = users.remove(userName) == null
}
private val loggedInUsers = mutableMapOf<String, String>()
fun login(userName: String, password: String) = Users[userName]?.let {
if (it.hash != hashPassword(password, it.salt))
null
else {
var cookie: String
do {
cookie = getRandomBase64().replace("=", "_")
} while (cookie in loggedInUsers.keys)
loggedInUsers[cookie] = userName
cookie
}
}
fun isLoggedIn(cookie: String) = loggedInUsers[cookie]
fun logout(cookie: String) = loggedInUsers.remove(cookie) != null
private fun hashPassword(password: String, salt: String, iterations: Int = 8, keyLength: Int = 256): String {
val spec = PBEKeySpec(password.toCharArray(), Base64.getDecoder().decode(salt), iterations, keyLength)
val skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val hash = skf.generateSecret(spec).encoded
return Base64.getEncoder().encodeToString(hash)
}
private fun getRandomBase64(len: Int = 8): String {
val bytes = ByteArray(len)
SecureRandom.getInstanceStrong().nextBytes(bytes)
return Base64.getEncoder().encodeToString(bytes)
}
}

View File

@ -0,0 +1,157 @@
package dev.asdf00.visionfive.hc.server
import com.sun.net.httpserver.HttpExchange
import dev.asdf00.visionfive.hc.GlobalDataStore
import java.nio.charset.StandardCharsets
import java.util.*
fun main() {
val server = buildWebServer(8030) {
// data collection
endpoint("temp") handler@{ exchange ->
if (exchange.remoteAddress.address.toString() !in GlobalDataStore.allowedTempUpdates) {
exchange.replyCat(403)
}
exchange.requestURI.query.split("&").forEach {
if (it.startsWith("t=")) {
it.substring(2).toDoubleOrNull()?.let {
GlobalDataStore.temperature = it
GlobalDataStore.tempUpdated = Date()
exchange.replyCat(200)
} ?: run {
exchange.replyCat(400)
}
return@handler
}
}
exchange.replyCat(400)
}
// main landing page
endpoint("") { exchange ->
exchange.responseHeaders.add("Cache-Control", "no-store")
val token = getLoginToken(exchange)
LoginBehavior.isLoggedIn(token)?.let { uname ->
// logged in
exchange.sendReply(
//@formatter:off
200, ContentType.HTML, """
<html>
<head>
<title>VFHeatControl Landing Page</title>
</head>
<body>
<h1>Welcome to HeatControl</h1>
<p>You are logged in as ${uname}. To log out, click <a href="/login/out">here</a>.</p>
<br />
<p>
The current recorded temperature is currently ${String.format("%.1f",GlobalDataStore.temperature)} C
</p>
</body>
</html>
""".trimIndent().uft8()
//@formatter:on
)
} ?: run {
// logged out
exchange.sendReply(
200, ContentType.HTML, """
<html>
<head>
<title>VFHeatControl Landing Page</title>
</head>
<body>
<h1>Welcome to HeatControl</h1>
<p>Please log in to view the current temperature</p>
<br />
<p>
Login Form
<br />
<form action="/login/in" method="post">
<label for="uname">user name:</label><br />
<input type="text" id="uname" name="uname"><br />
<label for="pwd">password:</label><br />
<input type="password" id="pwd" name="pwd"><br />
<input type="submit" value="Submit">
</form>
</p>
</body>
</html>
""".trimIndent().uft8()
)
}
}
// login logic
path("login") {
endpoint("in") handler@{ exchange ->
if (exchange.requestMethod != "POST") {
exchange.sendReply(400, ContentType.PLAIN, "wrong request method".uft8())
return@handler
}
val (userName, pwd) = exchange.requestBody.readAllBytes().utf8().split("&").map {
it.split("=").let {
if (it.size == 2) it[1] else ""
}
}.let {
if (it.size == 2) Pair(it[0], it[1]) else Pair("", "")
}
LoginBehavior.login(userName, pwd)?.let {
exchange.responseHeaders.add("Set-Cookie", "auth=${it}; Max-Age=600; Path=/")
exchange.sendReply(
200,
ContentType.HTML,
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
)
} ?: run {
exchange.sendReply(
400,
ContentType.HTML,
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
)
}
}
endpoint("out") { exchange ->
val token = getLoginToken(exchange)
if (LoginBehavior.logout(token)) {
exchange.responseHeaders.add("Set-Cookie", "auth=; Path=/")
exchange.sendReply(
200,
ContentType.HTML,
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
)
} else {
exchange.sendReply(
400,
ContentType.HTML,
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
)
}
}
}
}
server.start()
println("HttpServer is running, press CTRL+C to exit")
print("IP address of temperature sensor: ")
GlobalDataStore.allowedTempUpdates += "/${readln()}"
while (!Thread.interrupted()) {
println()
print("Username to add: ")
val userName = readln()
print("Password for '$userName': ")
val pwd = readln()
LoginBehavior.Users[userName] = pwd
}
}
fun String.uft8() = toByteArray(StandardCharsets.UTF_8)
fun ByteArray.utf8() = String(this, StandardCharsets.UTF_8)
private fun getLoginToken(exchange: HttpExchange) =
exchange.requestHeaders["Cookie"]
?.let { if (it.size > 0) it[0] else null }
?.let { it.split("=").let { if (it.size == 2) it[1] else "" } }
?: ""

View File

@ -0,0 +1,101 @@
package dev.asdf00.visionfive.hc.server
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executor
import java.util.concurrent.Executors
fun buildWebServer(port: Int, rootPath: String = "", executor: Executor = Executors.newSingleThreadExecutor(), bld: WebServerBuilder.() -> Unit): HttpServer {
val bdr = WebServerBuilder(rootPath)
bdr.bld()
val server = HttpServer.create(InetSocketAddress(port), 0)
server.executor = executor
fun WebServerBuilder.collect(prevPath: String = "") {
val curPath =
if (ownPath == "")
prevPath
else
"$prevPath/$ownPath"
if (curPath.endsWith("/")) {
throw IllegalStateException("Invalid path '$curPath' encountered while building server!")
}
for (ep in endpoints) {
server.createContext("$curPath/${ep.path}", ep.code)
}
for (inner in innerPaths) {
inner.collect(curPath)
}
}
bdr.collect()
return server
}
// =====================================================================================================================
// ServerBuilder
// =====================================================================================================================
class WebServerBuilder(internal val ownPath: String) {
internal val endpoints = mutableListOf<Endpoint>()
internal val innerPaths = mutableListOf<WebServerBuilder>()
internal val usedPaths = mutableSetOf<String>()
fun path(innerPath: String, bld: WebServerBuilder.() -> Unit) {
if (!usedPaths.add(innerPath)){
throw IllegalStateException("'$innerPath' already exists within '$ownPath'!")
}
val bdr = WebServerBuilder(innerPath)
bdr.bld()
innerPaths.add(bdr)
}
fun endpoint(location: String, code: (HttpExchange) -> Unit) {
if (!usedPaths.add(location)) {
throw IllegalStateException("'$location' already exists within '$ownPath'!")
}
usedPaths.add(location)
endpoints.add(Endpoint(location, code))
}
fun String.endpoint(location: String, type: ContentType = ContentType.HTML, rCode: Int = 200) {
endpoint(location) {
it.sendReply(rCode, type, toByteArray(StandardCharsets.UTF_8))
}
}
}
// =====================================================================================================================
// Helper Types
// =====================================================================================================================
enum class ContentType(val text: String) {
PLAIN("text/plain"),
HTML("text/html"),
JS("application/javascript"),
JSON("application/json")
}
data class Endpoint(
val path: String,
val code: (HttpExchange) -> Unit)
// =====================================================================================================================
// Helper Methods
// =====================================================================================================================
fun HttpExchange.sendReply(code: Int, type: ContentType, data: ByteArray) {
responseHeaders.add("Content-type", type.text)
sendResponseHeaders(code, data.size.toLong())
responseBody.use {
it.write(data)
}
}
fun HttpExchange.replyCat(code: Int) {
this.sendReply(code, ContentType.HTML, "<img src=\"https://http.cat/${code}\" />".uft8())
}

11
src/test/kotlin/Sender.kt Normal file
View File

@ -0,0 +1,11 @@
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
fun main() {
val client = HttpClient.newHttpClient()
// val req = HttpRequest.newBuilder(URI.create("http://192.168.178.71:8030/temp?t=34.5")).GET().build()
val req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8030/temp?t=34.5")).GET().build()
println("response: ${client.send(req, HttpResponse.BodyHandlers.ofString()).statusCode()}")
}