Skip to content
This repository was archived by the owner on Jun 5, 2026. It is now read-only.

Commit 3078770

Browse files
committed
sync: import Gitea changes for v1.5.3
1 parent 56324ad commit 3078770

6 files changed

Lines changed: 253 additions & 10 deletions

File tree

HomelabAndroid/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId = "com.homelab.app"
1515
minSdk = 26
1616
targetSdk = 36
17-
versionCode = 33
18-
versionName = "1.5.2"
17+
versionCode = 34
18+
versionName = "1.5.3"
1919
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2020

2121
vectorDrawables {

HomelabAndroid/app/src/main/java/com/homelab/app/data/remote/api/MaltrailApi.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.homelab.app.data.remote.api
22

33
import kotlinx.serialization.json.JsonElement
4+
import okhttp3.ResponseBody
45
import retrofit2.http.GET
56
import retrofit2.http.Header
67
import retrofit2.http.Query
@@ -18,5 +19,5 @@ interface MaltrailApi {
1819
@Header("X-Homelab-Service") service: String = "Maltrail",
1920
@Header("X-Homelab-Instance-Id") instanceId: String,
2021
@Query("date") date: String
21-
): JsonElement
22+
): ResponseBody
2223
}

HomelabAndroid/app/src/main/java/com/homelab/app/data/repository/MaltrailRepository.kt

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
1515
import kotlinx.coroutines.async
1616
import kotlinx.coroutines.coroutineScope
1717
import kotlinx.coroutines.withContext
18+
import kotlinx.serialization.json.Json
1819
import kotlinx.serialization.json.JsonArray
1920
import kotlinx.serialization.json.JsonElement
2021
import kotlinx.serialization.json.JsonNull
@@ -34,7 +35,7 @@ data class MaltrailCountPoint(
3435
) {
3536
val apiDate: String
3637
get() = Instant.ofEpochSecond(timestamp)
37-
.atZone(ZoneOffset.UTC)
38+
.atZone(ZoneId.systemDefault())
3839
.toLocalDate()
3940
.format(DateTimeFormatter.ISO_LOCAL_DATE)
4041

@@ -149,7 +150,7 @@ class MaltrailRepository @Inject constructor(
149150
}
150151

151152
suspend fun getEvents(instanceId: String, date: String): List<MaltrailEvent> {
152-
return parseEvents(api.getEvents(instanceId = instanceId, date = date))
153+
return parseEvents(api.getEvents(instanceId = instanceId, date = date).string())
153154
}
154155

155156
suspend fun getSummary(instanceId: String): MaltrailSummary {
@@ -236,6 +237,11 @@ class MaltrailRepository @Inject constructor(
236237
}.sortedByDescending { it.timestamp }
237238
}
238239

240+
private fun parseEvents(raw: String): List<MaltrailEvent> {
241+
val jsonEvents = runCatching { parseEvents(Json.parseToJsonElement(raw)) }.getOrDefault(emptyList())
242+
return jsonEvents.ifEmpty { parseTextEvents(raw) }
243+
}
244+
239245
private fun parseEvents(element: JsonElement): List<MaltrailEvent> {
240246
return extractObjectArray(element, "events", "data", "items", "results").mapIndexed { index, obj ->
241247
val raw = obj.toFlatStringMap()
@@ -263,6 +269,109 @@ class MaltrailRepository @Inject constructor(
263269
}
264270
}
265271

272+
private fun parseTextEvents(raw: String): List<MaltrailEvent> {
273+
return raw.lineSequence()
274+
.map { it.trim() }
275+
.filter { it.isNotBlank() }
276+
.mapIndexedNotNull { index, line ->
277+
val parts = splitLogLine(line)
278+
if (parts.size < 10) {
279+
return@mapIndexedNotNull MaltrailEvent(
280+
id = "raw-$index-${line.hashCode()}",
281+
timestamp = null,
282+
source = null,
283+
destination = null,
284+
protocolName = null,
285+
trail = null,
286+
severity = null,
287+
sensor = null,
288+
info = line,
289+
rawFields = mapOf("raw" to line)
290+
)
291+
}
292+
293+
val hasSplitTimestamp = parts.getOrNull(0)?.matches(Regex("\\d{4}-\\d{2}-\\d{2}")) == true &&
294+
parts.getOrNull(1)?.contains(":") == true
295+
val offset = if (hasSplitTimestamp) 1 else 0
296+
val timestamp = if (hasSplitTimestamp) "${parts[0]} ${parts[1]}" else parts[0]
297+
val sensor = parts.getOrNull(1 + offset)
298+
val source = parts.getOrNull(2 + offset)
299+
val sourcePort = parts.getOrNull(3 + offset)
300+
val destination = parts.getOrNull(4 + offset)
301+
val destinationPort = parts.getOrNull(5 + offset)
302+
val protocol = parts.getOrNull(6 + offset)
303+
val trailType = parts.getOrNull(7 + offset)
304+
val trail = parts.getOrNull(8 + offset)
305+
val info = parts.drop(9 + offset).joinToString(" ").ifBlank { null }
306+
val rawFields = linkedMapOf(
307+
"timestamp" to timestamp,
308+
"sensor" to sensor,
309+
"src_ip" to source,
310+
"src_port" to sourcePort,
311+
"dst_ip" to destination,
312+
"dst_port" to destinationPort,
313+
"protocol" to protocol,
314+
"type" to trailType,
315+
"trail" to trail,
316+
"info" to info
317+
).mapNotNull { (key, value) -> value?.takeIf { it.isNotBlank() }?.let { key to it } }.toMap()
318+
319+
MaltrailEvent(
320+
id = "$timestamp|$source|$destination|$trail|$index",
321+
timestamp = timestamp,
322+
source = source,
323+
destination = destination,
324+
protocolName = protocol,
325+
trail = trail,
326+
severity = inferSeverity(info, trailType),
327+
sensor = sensor,
328+
info = info,
329+
rawFields = rawFields
330+
)
331+
}
332+
.toList()
333+
}
334+
335+
private fun splitLogLine(line: String): List<String> {
336+
val result = mutableListOf<String>()
337+
val current = StringBuilder()
338+
var inQuotes = false
339+
var escaping = false
340+
341+
for (char in line) {
342+
when {
343+
escaping -> {
344+
current.append(char)
345+
escaping = false
346+
}
347+
char == '\\' && inQuotes -> escaping = true
348+
char == '"' -> inQuotes = !inQuotes
349+
char.isWhitespace() && !inQuotes -> {
350+
if (current.isNotEmpty()) {
351+
result += current.toString()
352+
current.clear()
353+
}
354+
}
355+
else -> current.append(char)
356+
}
357+
}
358+
359+
if (current.isNotEmpty()) {
360+
result += current.toString()
361+
}
362+
return result
363+
}
364+
365+
private fun inferSeverity(info: String?, trailType: String?): String? {
366+
val value = "${info.orEmpty()} ${trailType.orEmpty()}".lowercase(Locale.ROOT)
367+
return when {
368+
"malware" in value || "ransom" in value || "trojan" in value -> "high"
369+
"malicious" in value || "attack" in value || "scanner" in value -> "medium"
370+
"suspicious" in value -> "low"
371+
else -> null
372+
}
373+
}
374+
266375
private fun extractObjectArray(element: JsonElement, vararg keys: String): List<JsonObject> {
267376
when (element) {
268377
is JsonArray -> return element.mapNotNull { it as? JsonObject }

HomelabSwift/Homelab/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@
5353
<key>CFBundlePackageType</key>
5454
<string>$(PRODUCT_BUNDLE_TYPE)</string>
5555
<key>CFBundleShortVersionString</key>
56-
<string>1.5.2</string>
56+
<string>1.5.3</string>
5757
<key>CFBundleVersion</key>
58-
<string>33</string>
58+
<string>34</string>
5959
<key>LSApplicationQueriesSchemes</key>
6060
<array>
6161
<string>tailscale</string>

HomelabSwift/Homelab/Models/Maltrail/MaltrailModels.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ enum MaltrailDateFormatting {
7171
let formatter = DateFormatter()
7272
formatter.calendar = Calendar(identifier: .gregorian)
7373
formatter.locale = Locale(identifier: "en_US_POSIX")
74-
formatter.timeZone = TimeZone(secondsFromGMT: 0)
7574
formatter.dateFormat = "yyyy-MM-dd"
7675
return formatter.string(from: date)
7776
}

HomelabSwift/Homelab/Networking/Maltrail/MaltrailAPIClient.swift

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,18 @@ actor MaltrailAPIClient {
182182
}
183183

184184
private func parseEvents(from data: Data) throws -> [MaltrailEvent] {
185-
let json = try JSONSerialization.jsonObject(with: data)
186-
let rows = eventRows(from: json)
185+
if let json = try? JSONSerialization.jsonObject(with: data) {
186+
let rows = eventRows(from: json)
187+
if !rows.isEmpty {
188+
return parseEventRows(rows)
189+
}
190+
}
191+
192+
let raw = String(data: data, encoding: .utf8) ?? ""
193+
return parseTextEvents(raw)
194+
}
195+
196+
private func parseEventRows(_ rows: [[String: Any]]) -> [MaltrailEvent] {
187197
return rows.enumerated().map { index, row in
188198
let raw = row.reduce(into: [String: String]()) { partial, item in
189199
if let text = Self.stringValue(item.value), !text.isEmpty {
@@ -207,6 +217,117 @@ actor MaltrailAPIClient {
207217
}
208218
}
209219

220+
private func parseTextEvents(_ raw: String) -> [MaltrailEvent] {
221+
raw.split(whereSeparator: \.isNewline)
222+
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
223+
.filter { !$0.isEmpty }
224+
.enumerated()
225+
.map { index, line in
226+
let parts = splitLogLine(line)
227+
guard parts.count >= 10 else {
228+
return MaltrailEvent(
229+
id: "raw-\(index)-\(line.hashValue)",
230+
timestamp: nil,
231+
source: nil,
232+
destination: nil,
233+
protocolName: nil,
234+
trail: nil,
235+
severity: nil,
236+
sensor: nil,
237+
info: line,
238+
rawFields: ["raw": line]
239+
)
240+
}
241+
242+
let hasSplitTimestamp = parts.indices.contains(1)
243+
&& parts[0].range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil
244+
&& parts[1].contains(":")
245+
let offset = hasSplitTimestamp ? 1 : 0
246+
let timestamp = hasSplitTimestamp ? "\(parts[0]) \(parts[1])" : parts[0]
247+
let sensor = parts[safe: 1 + offset]
248+
let source = parts[safe: 2 + offset]
249+
let sourcePort = parts[safe: 3 + offset]
250+
let destination = parts[safe: 4 + offset]
251+
let destinationPort = parts[safe: 5 + offset]
252+
let protocolName = parts[safe: 6 + offset]
253+
let trailType = parts[safe: 7 + offset]
254+
let trail = parts[safe: 8 + offset]
255+
let infoPartsStart = 9 + offset
256+
let info = parts.indices.contains(infoPartsStart)
257+
? parts[infoPartsStart...].joined(separator: " ").nonEmptyValue
258+
: nil
259+
let rawFields = [
260+
"timestamp": timestamp,
261+
"sensor": sensor,
262+
"src_ip": source,
263+
"src_port": sourcePort,
264+
"dst_ip": destination,
265+
"dst_port": destinationPort,
266+
"protocol": protocolName,
267+
"type": trailType,
268+
"trail": trail,
269+
"info": info
270+
].compactMapValues { $0?.nonEmptyValue }
271+
272+
return MaltrailEvent(
273+
id: "\(timestamp)|\(source ?? "")|\(destination ?? "")|\(trail ?? "")|\(index)",
274+
timestamp: timestamp,
275+
source: source,
276+
destination: destination,
277+
protocolName: protocolName,
278+
trail: trail,
279+
severity: inferSeverity(info: info, trailType: trailType),
280+
sensor: sensor,
281+
info: info,
282+
rawFields: rawFields
283+
)
284+
}
285+
}
286+
287+
private func splitLogLine(_ line: String) -> [String] {
288+
var result: [String] = []
289+
var current = ""
290+
var inQuotes = false
291+
var escaping = false
292+
293+
for character in line {
294+
if escaping {
295+
current.append(character)
296+
escaping = false
297+
} else if character == "\\" && inQuotes {
298+
escaping = true
299+
} else if character == "\"" {
300+
inQuotes.toggle()
301+
} else if character.isWhitespace && !inQuotes {
302+
if !current.isEmpty {
303+
result.append(current)
304+
current.removeAll(keepingCapacity: true)
305+
}
306+
} else {
307+
current.append(character)
308+
}
309+
}
310+
311+
if !current.isEmpty {
312+
result.append(current)
313+
}
314+
return result
315+
}
316+
317+
private func inferSeverity(info: String?, trailType: String?) -> String? {
318+
let value = "\(info ?? "") \(trailType ?? "")".lowercased()
319+
if value.contains("malware") || value.contains("ransom") || value.contains("trojan") {
320+
return "high"
321+
}
322+
if value.contains("malicious") || value.contains("attack") || value.contains("scanner") {
323+
return "medium"
324+
}
325+
if value.contains("suspicious") {
326+
return "low"
327+
}
328+
return nil
329+
}
330+
210331
private func eventRows(from json: Any) -> [[String: Any]] {
211332
if let rows = json as? [[String: Any]] {
212333
return rows
@@ -408,3 +529,16 @@ actor MaltrailAPIClient {
408529
return .custom(error.localizedDescription)
409530
}
410531
}
532+
533+
private extension Array {
534+
subscript(safe index: Int) -> Element? {
535+
indices.contains(index) ? self[index] : nil
536+
}
537+
}
538+
539+
private extension String {
540+
var nonEmptyValue: String? {
541+
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
542+
return trimmed.isEmpty ? nil : trimmed
543+
}
544+
}

0 commit comments

Comments
 (0)