Commit a030f922 authored by akwizgran's avatar akwizgran

Merge branch 'headless' into 'master'

Add Briar headless client that exposes a REST API

See merge request briar/briar!931
parents 8a15fb24 b3615b4a
......@@ -36,6 +36,9 @@
<option name="JD_ALIGN_PARAM_COMMENTS" value="false" />
<option name="JD_ALIGN_EXCEPTION_COMMENTS" value="false" />
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<Objective-C-extensions>
<file>
<option name="com.jetbrains.cidr.lang.util.OCDeclarationKind" value="Import" />
......@@ -257,5 +260,11 @@
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="PARAMETER_ANNOTATION_WRAP" value="1" />
<option name="VARIABLE_ANNOTATION_WRAP" value="1" />
<option name="ENUM_CONSTANTS_WRAP" value="1" />
</codeStyleSettings>
</code_scheme>
</component>
\ No newline at end of file
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All in briar-headless" type="AndroidJUnit" factoryName="Android JUnit" nameIsGenerated="true">
<extension name="coverage" enabled="false" merge="false" sample_coverage="true" runner="idea" />
<module name="briar-headless" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PACKAGE_NAME" value="" />
<option name="MAIN_CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="package" />
<option name="VM_PARAMETERS" value="" />
<option name="PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$/briar-headless" />
<option name="ENV_VARIABLES" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="TEST_SEARCH_SCOPE">
<value defaultName="singleModule" />
</option>
<envs />
<patterns />
<method />
</configuration>
</component>
\ No newline at end of file
......@@ -24,6 +24,7 @@
<option name="RunConfigurationTask" enabled="true" run_configuration_name="All tests in bramble-android" run_configuration_type="AndroidJUnit" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="All tests in bramble-java" run_configuration_type="AndroidJUnit" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="All tests in briar-core" run_configuration_type="AndroidJUnit" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="All in briar-headless" run_configuration_type="AndroidJUnit" />
</method>
</configuration>
</component>
</component>
\ No newline at end of file
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="briar-headless" type="JetRunConfigurationType" factoryName="Kotlin" singleton="true">
<extension name="coverage" enabled="false" merge="false" sample_coverage="true" runner="idea" />
<option name="VM_PARAMETERS" value="" />
<option name="PROGRAM_PARAMETERS" value="-v" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.briarproject.briar.headless.MainKt" />
<option name="WORKING_DIRECTORY" value="" />
<module name="briar-headless" />
<envs />
<method>
<option name="Gradle.BeforeRunTask" enabled="true" tasks="jar" externalProjectPath="$PROJECT_DIR$/briar-headless" vmOptions="" scriptParameters="" />
</method>
</configuration>
</component>
\ No newline at end of file
# Briar REST API
This is a headless Briar peer that exposes a REST API
with an integrated HTTP server instead of a traditional user interface.
You can use this API to script the peer behavior
or to develop your own user interface for it.
## How to use
The REST API peer comes as a `jar` file
and needs a Java Runtime Environment (JRE) that supports at least Java 8.
It currently works only on GNU/Linux operating systems.
You can start the peer (and its API server) like this:
$ java -jar briar-headless/build/libs/briar-headless.jar
It is possible to put parameters at the end.
Try `--help` for a list of options.
On the first start, it will ask you to create a user account:
$ java -jar briar-headless.jar
No account found. Let's create one!
Nickname: testuser
Password:
After entering a password, it will start up without further output.
Use the `-v` option if you prefer more verbose logging.
By default, Briar creates a folder `~/.briar` where it stores its database and other files.
There you also find the authentication token which is required to interact with the API:
$ cat ~/.briar/auth_token
DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=
You can test that things work as expected by running:
$ curl -H "Authorization: Bearer DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=" http://127.0.0.1:7000/v1/contacts
[]
The answer is an empty JSON array, because you don't have any contacts.
Note that the HTTP request sets an `Authorization` header with the bearer token.
A missing or wrong token will result in a `401` response.
## REST API
### Listing all contacts
`GET /v1/contacts`
Returns a JSON array of contacts:
```json
{
"author": {
"formatVersion": 1,
"id": "y1wkIzAimAbYoCGgWxkWlr6vnq1F8t1QRA/UMPgI0E0=",
"name": "Test",
"publicKey": "BDu6h1S02bF4W6rgoZfZ6BMjTj/9S9hNN7EQoV05qUo="
},
"contactId": 1,
"verified": true
}
```
### Adding a contact
*Not yet implemented*
The only workaround is to add a contact to the Briar app running on a rooted Android phone
and then move its database (and key files) to the headless peer.
### Listing all private messages
`GET /messages/{contactId}`
The `{contactId}` is the `contactId` of the contact (`1` in the example above).
It returns a JSON array of private messages:
```json
{
"contactId": 1,
"groupId": "oRRvCri85UE2XGcSloAKt/u8JDcMkmDc26SOMouxr4U=",
"id": "ZGDrlpCxO9v7doO4Bmijh95QqQDykaS4Oji/mZVMIJ8=",
"local": true,
"read": true,
"seen": true,
"sent": true,
"text": "test",
"timestamp": 1537376633850,
"type": "PrivateMessage"
}
```
If `local` is `true`, the message was sent by the Briar peer instead of its remote contact.
Attention: There can messages of other `type`s where the message `text` is `null`.
### Writing a private message
`POST /messages/{contactId}`
The text of the message should be posted as JSON:
```json
{
"text": "Hello World!"
}
```
### Listing blog posts
`GET /v1/blogs/posts`
Returns a JSON array of blog posts:
```json
{
"author": {
"formatVersion": 1,
"id": "VNKXkaERPpXmZuFbHHwYT6Qc148D+KNNxQ4hwtx7Kq4=",
"name": "Test",
"publicKey": "NbwpQWjS3gHMjjDQIASIy/j+bU6NRZnSRT8X8FKDoN4="
},
"authorStatus": "ourselves",
"id": "X1jmHaYfrX47kT5OEd0OD+p/bptyR92IvuOBYSgxETM=",
"parentId": null,
"read": true,
"rssFeed": false,
"text": "Test Post Content",
"timestamp": 1535397886749,
"timestampReceived": 1535397886749,
"type": "post"
}
```
### Writing a blog post
`POST /v1/blogs/posts`
The text of the blog post should be posted as JSON:
```json
{
"text": "Hello Blog World!"
}
```
## Websocket API
The Briar peer uses a websocket to notify a connected API client about new events.
`WS /v1/ws`
The websocket request must use basic auth,
with the authentication token as the username and a blank password.
You can test connecting to the websocket with curl:
$ curl --no-buffer \
--header "Connection: Upgrade" \
--header "Upgrade: websocket" \
--header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
--header "Sec-WebSocket-Version: 13" \
http://DZbfoUie8sjap7CSDR9y6cgJCojV+xUITTIFbgtAgqk=@127.0.0.1:7000/v1/ws
The headers are only required when testing with curl.
Your websocket client will most likely add these headers automatically.
### Receiving new private messages
When the Briar peer receives a new private message,
it will send a JSON object to connected websocket clients:
```json
{
"data": {
"contactId": 1,
"groupId": "oRRvCri85UE2XGcSloAKt/u8JDcMkmDc26SOMouxr4U=",
"id": "JBc+ogQIok/yr+7XtxN2iQgNfzw635mHikNaP5QOEVs=",
"local": false,
"read": false,
"seen": false,
"sent": false,
"text": "Test Message",
"timestamp": 1537389146088,
"type": "PrivateMessage"
},
"name": "PrivateMessageReceivedEvent",
"type": "event"
}
```
Note that the JSON object in `data` is exactly what the REST API returns
when listing private messages.
plugins {
id 'java'
id 'idea'
id 'org.jetbrains.kotlin.jvm' version '1.2.70'
id 'org.jetbrains.kotlin.kapt' version '1.2.70'
id 'witness'
}
apply from: 'witness.gradle'
sourceCompatibility = 1.8
targetCompatibility = 1.8
dependencies {
implementation project(path: ':briar-core', configuration: 'default')
implementation project(path: ':bramble-java', configuration: 'default')
implementation 'io.javalin:javalin:2.2.0'
implementation 'org.slf4j:slf4j-simple:1.7.25'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.6'
implementation 'com.github.ajalt:clikt:1.5.0'
kapt 'com.google.dagger:dagger-compiler:2.0.2'
testImplementation project(path: ':bramble-api', configuration: 'testOutput')
testImplementation project(path: ':bramble-core', configuration: 'testOutput')
def junitVersion = '5.2.0'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion"
testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion"
testRuntime "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
testImplementation "io.mockk:mockk:1.8.6"
testImplementation "org.skyscreamer:jsonassert:1.5.0"
}
jar {
manifest {
attributes(
'Main-Class': 'org.briarproject.briar.headless.MainKt'
)
}
from {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
}
}
// At the moment for non-Android projects we need to explicitly mark the code generated by kapt
// as 'generated source code' for correct highlighting and resolve in IDE.
idea {
module {
sourceDirs += file('build/generated/source/kapt/main')
generatedSourceDirs += file('build/generated/source/kapt/main')
}
}
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
}
package org.briarproject.bramble.identity
import org.briarproject.bramble.api.identity.Author
import org.briarproject.briar.headless.json.JsonDict
fun Author.output() = JsonDict(
"formatVersion" to formatVersion,
"id" to id.bytes,
"name" to name,
"publicKey" to publicKey
)
fun Author.Status.output() = name.toLowerCase()
package org.briarproject.briar.headless
import dagger.Component
import org.briarproject.bramble.BrambleCoreEagerSingletons
import org.briarproject.bramble.BrambleCoreModule
import org.briarproject.bramble.account.AccountModule
import org.briarproject.bramble.system.DesktopSecureRandomModule
import org.briarproject.briar.BriarCoreEagerSingletons
import org.briarproject.briar.BriarCoreModule
import java.security.SecureRandom
import javax.inject.Singleton
@Component(
modules = [
BrambleCoreModule::class,
BriarCoreModule::class,
DesktopSecureRandomModule::class,
AccountModule::class,
HeadlessModule::class
]
)
@Singleton
internal interface BriarHeadlessApp : BrambleCoreEagerSingletons, BriarCoreEagerSingletons {
fun getRouter(): Router
fun getSecureRandom(): SecureRandom
}
package org.briarproject.briar.headless
import com.github.ajalt.clikt.core.UsageError
import com.github.ajalt.clikt.output.TermUi.echo
import com.github.ajalt.clikt.output.TermUi.prompt
import org.briarproject.bramble.api.account.AccountManager
import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator
import org.briarproject.bramble.api.crypto.PasswordStrengthEstimator.QUITE_WEAK
import org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH
import org.briarproject.bramble.api.lifecycle.LifecycleManager
import java.lang.System.exit
import javax.annotation.concurrent.Immutable
import javax.inject.Inject
import javax.inject.Singleton
@Immutable
@Singleton
internal class BriarService
@Inject
constructor(
private val accountManager: AccountManager,
private val lifecycleManager: LifecycleManager,
private val passwordStrengthEstimator: PasswordStrengthEstimator
) {
fun start() {
if (!accountManager.accountExists()) {
createAccount()
} else {
val password = prompt("Password", hideInput = true)
?: throw UsageError("Could not get password. Is STDIN connected?")
if (!accountManager.signIn(password)) {
echo("Error: Password invalid")
exit(1)
}
}
val dbKey = accountManager.databaseKey ?: throw AssertionError()
lifecycleManager.startServices(dbKey)
}
fun stop() {
lifecycleManager.stopServices()
lifecycleManager.waitForShutdown()
}
private fun createAccount() {
echo("No account found. Let's create one!\n\n")
val nickname = prompt("Nickname") { nickname ->
if (nickname.length > MAX_AUTHOR_NAME_LENGTH)
throw UsageError("Please choose a shorter nickname!")
nickname
}
val password =
prompt("Password", hideInput = true, requireConfirmation = true) { password ->
if (passwordStrengthEstimator.estimateStrength(password) < QUITE_WEAK)
throw UsageError("Please enter a stronger password!")
password
}
if (nickname == null || password == null)
throw UsageError("Could not get account information. Is STDIN connected?")
accountManager.createAccount(nickname, password)
}
}
package org.briarproject.briar.headless
import org.briarproject.bramble.api.db.DatabaseConfig
import java.io.File
import java.lang.Long.MAX_VALUE
internal class HeadlessDatabaseConfig(private val dbDir: File, private val keyDir: File) :
DatabaseConfig {
override fun getDatabaseDirectory(): File {
return dbDir
}
override fun getDatabaseKeyDirectory(): File {
return keyDir
}
override fun getMaxSize(): Long {
return MAX_VALUE
}
}
package org.briarproject.briar.headless
import dagger.Module
import dagger.Provides
import org.briarproject.bramble.api.crypto.CryptoComponent
import org.briarproject.bramble.api.crypto.PublicKey
import org.briarproject.bramble.api.db.DatabaseConfig
import org.briarproject.bramble.api.event.EventBus
import org.briarproject.bramble.api.lifecycle.IoExecutor
import org.briarproject.bramble.api.network.NetworkManager
import org.briarproject.bramble.api.plugin.BackoffFactory
import org.briarproject.bramble.api.plugin.PluginConfig
import org.briarproject.bramble.api.plugin.duplex.DuplexPluginFactory
import org.briarproject.bramble.api.plugin.simplex.SimplexPluginFactory
import org.briarproject.bramble.api.reporting.DevConfig
import org.briarproject.bramble.api.reporting.ReportingConstants.DEV_ONION_ADDRESS
import org.briarproject.bramble.api.reporting.ReportingConstants.DEV_PUBLIC_KEY_HEX
import org.briarproject.bramble.api.system.Clock
import org.briarproject.bramble.api.system.LocationUtils
import org.briarproject.bramble.api.system.ResourceProvider
import org.briarproject.bramble.network.JavaNetworkModule
import org.briarproject.bramble.plugin.tor.CircumventionModule
import org.briarproject.bramble.plugin.tor.CircumventionProvider
import org.briarproject.bramble.plugin.tor.LinuxTorPluginFactory
import org.briarproject.bramble.system.JavaSystemModule
import org.briarproject.bramble.util.StringUtils.fromHexString
import org.briarproject.briar.headless.blogs.HeadlessBlogModule
import org.briarproject.briar.headless.contact.HeadlessContactModule
import org.briarproject.briar.headless.event.HeadlessEventModule
import org.briarproject.briar.headless.forums.HeadlessForumModule
import org.briarproject.briar.headless.messaging.HeadlessMessagingModule
import java.io.File
import java.security.GeneralSecurityException
import java.util.Collections.emptyList
import java.util.concurrent.Executor
import javax.inject.Singleton
import javax.net.SocketFactory
@Module(
includes = [
JavaNetworkModule::class,
JavaSystemModule::class,
CircumventionModule::class,
HeadlessBlogModule::class,
HeadlessContactModule::class,
HeadlessEventModule::class,
HeadlessForumModule::class,
HeadlessMessagingModule::class
]
)
internal class HeadlessModule(private val appDir: File) {
@Provides
@Singleton
internal fun provideDatabaseConfig(): DatabaseConfig {
val dbDir = File(appDir, "db")
val keyDir = File(appDir, "key")
return HeadlessDatabaseConfig(dbDir, keyDir)
}
@Provides
internal fun providePluginConfig(
@IoExecutor ioExecutor: Executor, torSocketFactory: SocketFactory,
backoffFactory: BackoffFactory, networkManager: NetworkManager,
locationUtils: LocationUtils, eventBus: EventBus,
resourceProvider: ResourceProvider,
circumventionProvider: CircumventionProvider, clock: Clock
): PluginConfig {
val torDirectory = File(appDir, "tor")
val tor = LinuxTorPluginFactory(
ioExecutor,
networkManager, locationUtils, eventBus, torSocketFactory,
backoffFactory, resourceProvider, circumventionProvider, clock,
torDirectory
)
val duplex = listOf<DuplexPluginFactory>(tor)
return object : PluginConfig {
override fun getDuplexFactories(): Collection<DuplexPluginFactory> {
return duplex
}
override fun getSimplexFactories(): Collection<SimplexPluginFactory> {
return emptyList()
}
override fun shouldPoll(): Boolean {
return true
}
}
}
@Provides
@Singleton
internal fun provideDevConfig(crypto: CryptoComponent): DevConfig {
return object : DevConfig {
override fun getDevPublicKey(): PublicKey {
try {
return crypto.messageKeyParser
.parsePublicKey(fromHexString(DEV_PUBLIC_KEY_HEX))
} catch (e: GeneralSecurityException) {
throw RuntimeException(e)
}
}
override fun getDevOnionAddress(): String {
return DEV_ONION_ADDRESS
}
override fun getReportDir(): File {
return File(appDir, "reportDir")
}
}
}
}
package org.briarproject.briar.headless
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.counted
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.int
import org.briarproject.bramble.BrambleCoreModule
import org.briarproject.briar.BriarCoreModule
import org.slf4j.impl.SimpleLogger.DEFAULT_LOG_LEVEL_KEY
import org.spongycastle.util.encoders.Base64.toBase64String
import java.io.File
import java.io.File.separator
import java.io.IOException
import java.lang.System.getProperty
import java.lang.System.setProperty
import java.nio.file.Files.setPosixFilePermissions
import java.nio.file.attribute.PosixFilePermission
import java.nio.file.attribute.PosixFilePermission.*
import java.security.SecureRandom
import java.util.logging.Level.*
import java.util.logging.LogManager
private const val DEFAULT_PORT = 7000
private val DEFAULT_DATA_DIR = getProperty("user.home") + separator + ".briar"
private class Main : CliktCommand(
name = "briar-headless",
help = "A Briar peer without GUI that exposes a REST and Websocket API"
) {
private val debug by option("--debug", "-d", help = "Enable printing of debug messages").flag(
default = false
)
private val verbosity by option(
"--verbose",
"-v",
help = "Print verbose log messages"
).counted()
private val port by option(
"--port",
help = "Bind the server to this port. Default: $DEFAULT_PORT",
metavar = "PORT",
envvar = "BRIAR_PORT"
).int().default(DEFAULT_PORT)
private val dataDir by option(
"--data-dir",
help = "The directory where Briar will store its files. Default: $DEFAULT_DATA_DIR",
metavar = "PATH",
envvar = "BRIAR_DATA_DIR"
).default(DEFAULT_DATA_DIR)
override fun run() {
// logging
val levelSlf4j = if (debug) "DEBUG" else when (verbosity) {
0 -> "WARN"
1 -> "INFO"
else -> "DEBUG"
}
val level = if (debug) ALL else when (verbosity) {
0 -> WARNING
1 -> INFO
else -> ALL
}
setProperty(DEFAULT_LOG_LEVEL_KEY, levelSlf4j)
LogManager.getLogManager().getLogger("").level = level
val dataDir = getDataDir()
val app =
DaggerBriarHeadlessApp.builder().headlessModule(HeadlessModule(dataDir)).build()
// We need to load the eager singletons directly after making the
// dependency graphs
BrambleCoreModule.initEagerSingletons(app)
BriarCoreModule.initEagerSingletons(app)
val authToken = getOrCreateAuthToken(dataDir, app.getSecureRandom())
app.getRouter().start(authToken, port, debug)
}
private fun getDataDir(): File {
val file = File(dataDir)
if (!file.exists() && !file.mkdirs()) {
throw IOException("Could not create directory: ${file.absolutePath}")
} else if (!file.isDirectory) {
throw IOException("Data dir is not a directory: ${file.absolutePath}")
}
val perms = HashSet<PosixFilePermission>()
perms.add(OWNER_READ)
perms.add(OWNER_WRITE)
perms.add(OWNER_EXECUTE)
setPosixFilePermissions(file.toPath(), perms)
return file
}
private fun getOrCreateAuthToken(dataDir: File, secureRandom: SecureRandom): String {
val tokenFile = File(dataDir, "auth_token")
return if (tokenFile.isFile) {
tokenFile.readText()
} else {
val authToken = createAuthToken(secureRandom)
tokenFile.writeText(authToken)
authToken
}
}
private fun createAuthToken(secureRandom: SecureRandom): String {
val bytes = ByteArray(32)
secureRandom.nextBytes(bytes)
return toBase64String(bytes)
}
}
fun main(args: Array<String>) = Main().main(args)