Compare commits

...
12 Commits
16 changed files with 128 additions and 60 deletions
+12
View File
@@ -43,6 +43,7 @@ jobs:
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-$(echo ${{matrix.architecture}} | tr / -)-${{steps.short-sha.outputs.sha}} --output type=tar,dest=output-${{matrix.architecture}}.tar .
- name: Strip binary
run: mkdir -p output/ && tar -xf output-${{matrix.architecture}}.tar -C output && rm output-${{matrix.architecture}}.tar && cd output/ && tar -cf ../agent-${{matrix.architecture}}.tar -C home/agent . && rm -rf output
# We'll make a GitHub release and push the build (tar) as an artifact
- uses: rickstaa/action-create-tag@v1
with:
tag: ${{ steps.short-sha.outputs.sha }}
@@ -54,6 +55,17 @@ jobs:
name: ${{ steps.short-sha.outputs.sha }}
tag: ${{ steps.short-sha.outputs.sha }}
artifacts: "agent-${{matrix.architecture}}.tar"
# Taken from GoReleaser's own release workflow.
# The available Snapcraft Action has some bugs described in the issue below.
# The mkdirs are a hack for https://github.com/goreleaser/goreleaser/issues/1715.
#- name: Setup Snapcraft
# run: |
# sudo apt-get update
# sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft
# mkdir -p $HOME/.cache/snapcraft/download
# mkdir -p $HOME/.cache/snapcraft/stage-packages
#- name: Use Snapcraft
# run: tar -xf agent-${{matrix.architecture}}.tar && snapcraft
build-other:
runs-on: ubuntu-latest
permissions:
+1 -1
View File
@@ -10,7 +10,7 @@ ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get upgrade -y && apt-get update && apt-get install -y --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
rm -rf /var/lib/apt/lists/*
+19 -4
View File
@@ -18,6 +18,7 @@
[![donate](https://brianmacdonald.github.io/Ethonate/svg/eth-donate-blue.svg)](https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d3144D95538fF4bE)
<a target="_blank" href="https://twitter.com/kerberosio?ref_src=twsrc%5Etfw"><img src="https://img.shields.io/twitter/url.svg?label=Follow%20%40kerberosio&style=social&url=https%3A%2F%2Ftwitter.com%2Fkerberosio" alt="Twitter Widget"></a>
[![Discord Shield](https://discordapp.com/api/guilds/1039619181731135499/widget.png?style=shield)](https://discord.gg/Bj77Vqfp2G)
[![kerberosio](https://snapcraft.io/kerberosio/badge.svg)](https://snapcraft.io/kerberosio)
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io) | [**View Demo**](https://demo.kerberos.io)
@@ -82,6 +83,16 @@ Run Kerberos Agent with [Balena Cloud](https://www.balena.io/) super powers. Mon
[![deploy with balena](https://balena.io/deploy.svg)](https://dashboard.balena-cloud.com/deploy?repoUrl=https://github.com/kerberos-io/balena-agent)
## Quickstart - Snap
Run Kerberos Agent with our [Snapcraft package](https://snapcraft.io/kerberosio).
snap install kerberosio
Once installed you can find your Kerberos Agent configration at `/var/snap/kerberosio/common`. Run the Kerberos Agent as following
kerberosio.agent -action=run -port=80
## A world of Kerberos Agents
The Kerberos Agent is an isolated and scalable video (surveillance) management agent with a strong focus on user experience, scalability, resilience, extension and integration. Next to the Kerberos Agent, Kerberos.io provides many other tools such as [Kerberos Factory](https://github.com/kerberos-io/factory), [Kerberos Vault](https://github.com/kerberos-io/vault) and [Kerberos Hub](https://github.com/kerberos-io/hub) to provide additional capabilities: bring your own cloud, bring your own storage, central overview, live streaming, machine learning etc.
@@ -120,6 +131,7 @@ We have documented the different deployment models [in the `deployments` directo
- [Terraform](https://github.com/kerberos-io/agent/tree/master/deployments#5-terraform)
- [Salt](https://github.com/kerberos-io/agent/tree/master/deployments#6-salt)
- [Balena](https://github.com/kerberos-io/agent/tree/master/deployments#8-balena)
- [Snap](https://github.com/kerberos-io/agent/tree/master/deployments#9-snap)
By default your Kerberos Agents will store all its configuration and recordings inside the container. To help you automate and have a more consistent data governance, you can attach volumes to configure and persist data of your Kerberos Agents, and/or configure each Kerberos Agent through environment variables.
@@ -183,8 +195,11 @@ Next to attaching the configuration file, it is also possible to override the co
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
| `AGENT_CAPTURE_RECORDING` | Toggle for enabling making recordings. | "true" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous or motion based recording. | "false" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous "true" or motion "false". | "false" |
| `AGENT_CAPTURE_PRERECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) before after motion event. | "10" |
| `AGENT_CAPTURE_POSTRECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) after motion event. | "20" |
| `AGENT_CAPTURE_MAXLENGTH` | The maximum length of a single recording (seconds). | "30" |
@@ -233,9 +248,9 @@ On opening of the GitHub Codespace, some dependencies will be installed. Once th
const dev = {
ENV: 'dev',
HOSTNAME: externalHost,
//API_URL: `${protocol}//${hostname}:8080/api`,
//URL: `${protocol}//${hostname}:8080`,
//WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
//API_URL: `${protocol}//${hostname}:80/api`,
//URL: `${protocol}//${hostname}:80`,
//WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
API_URL: `${protocol}//${externalHost}/api`,
+10
View File
@@ -67,3 +67,13 @@ Balena Cloud provide a seamless way of building and deploying applications at sc
Together with the Balena.io team we've build a Balena App, called [`video-surveillance`](https://hub.balena.io/apps/2064752/video-surveillance), which any can use to deploy a video surveillance system in a matter of minutes with all the expected management features you can think of.
> Learn more [about Kerberos Agent with Balena](https://github.com/kerberos-io/agent/tree/master/deployments/balena).
## 9. Snap
Run Kerberos Agent with our [Snapcraft package](https://snapcraft.io/kerberosio).
snap install kerberosio
Once installed you can find your Kerberos Agent configration at `/var/snap/kerberosio/common`. Run the Kerberos Agent as following
kerberosio.agent -action=run -port=80
+3 -3
View File
@@ -66,7 +66,7 @@ func main() {
flag.Parse()
timezone, _ := time.LoadLocation("CET")
log.Log.Init(timezone)
log.Log.Init(configDirectory, timezone)
switch action {
@@ -102,11 +102,11 @@ func main() {
// Check the folder permissions, it might be that we do not have permissions to write
// recordings, update the configuration or save snapshots.
utils.CheckDataDirectoryPermissions()
utils.CheckDataDirectoryPermissions(configDirectory)
// Set timezone
timezone, _ := time.LoadLocation(configuration.Config.Timezone)
log.Log.Init(timezone)
log.Log.Init(configDirectory, timezone)
// Check if we have a device Key or not, if not
// we will generate one.
+10 -10
View File
@@ -17,7 +17,7 @@ import (
"github.com/kerberos-io/joy4/av"
)
func CleanupRecordingDirectory(configuration *models.Configuration) {
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
autoClean := configuration.Config.AutoClean
if autoClean == "true" {
maxSize := configuration.Config.MaxDirectorySize
@@ -25,7 +25,7 @@ func CleanupRecordingDirectory(configuration *models.Configuration) {
maxSize = 300
}
// Total size of the recording directory.
recordingsDirectory := "./data/recordings"
recordingsDirectory := configDirectory + "/data/recordings"
size, err := utils.DirSize(recordingsDirectory)
if err == nil {
sizeInMB := size / 1000 / 1000
@@ -51,7 +51,7 @@ func CleanupRecordingDirectory(configuration *models.Configuration) {
}
}
func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration, communication *models.Communication, streams []av.CodecData) {
func HandleRecordStream(queue *pubsub.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, streams []av.CodecData) {
config := configuration.Config
@@ -134,13 +134,13 @@ func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration
}
// Create a symbol link.
fc, _ := os.Create("./data/cloud/" + name)
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
recordingStatus = "idle"
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configuration)
CleanupRecordingDirectory(configDirectory, configuration)
}
// If not yet started and a keyframe, let's make a recording
@@ -192,7 +192,7 @@ func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration
"769"
name = s + ".mp4"
fullName = "./data/recordings/" + name
fullName = configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("Recording started")
@@ -259,7 +259,7 @@ func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration
}
// Create a symbol link.
fc, _ := os.Create("./data/cloud/" + name)
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
recordingStatus = "idle"
@@ -315,7 +315,7 @@ func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration
"769"
name := s + ".mp4"
fullName := "./data/recordings/" + name
fullName := configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("HandleRecordStream: Recording started")
@@ -406,11 +406,11 @@ func HandleRecordStream(queue *pubsub.Queue, configuration *models.Configuration
}
// Create a symbol linc.
fc, _ := os.Create("./data/cloud/" + name)
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configuration)
CleanupRecordingDirectory(configDirectory, configuration)
}
}
+8 -8
View File
@@ -32,8 +32,8 @@ import (
"github.com/kerberos-io/agent/machinery/src/webrtc"
)
func PendingUpload() {
ff, err := utils.ReadDirectory("./data/cloud/")
func PendingUpload(configDirectory string) {
ff, err := utils.ReadDirectory(configDirectory + "/data/cloud/")
if err == nil {
for _, f := range ff {
log.Log.Info(f.Name())
@@ -41,12 +41,12 @@ func PendingUpload() {
}
}
func HandleUpload(configuration *models.Configuration, communication *models.Communication) {
func HandleUpload(configDirectory string, configuration *models.Configuration, communication *models.Communication) {
log.Log.Debug("HandleUpload: started")
config := configuration.Config
watchDirectory := "./data/cloud/"
watchDirectory := configDirectory + "/data/cloud/"
if config.Offline == "true" {
log.Log.Debug("HandleUpload: stopping as Offline is enabled.")
@@ -122,7 +122,7 @@ func HandleUpload(configuration *models.Configuration, communication *models.Com
// Check if we need to remove the original recording
// removeAfterUpload is set to false by default
if config.RemoveAfterUpload == "true" {
err := os.Remove("./data/recordings/" + fileName)
err := os.Remove(configDirectory + "/data/recordings/" + fileName)
if err != nil {
log.Log.Error("HandleUpload: " + err.Error())
}
@@ -614,7 +614,7 @@ func VerifyHub(c *gin.Context) {
// @Summary Will verify the persistence.
// @Description Will verify the persistence.
// @Success 200 {object} models.APIResponse
func VerifyPersistence(c *gin.Context) {
func VerifyPersistence(c *gin.Context, configDirectory string) {
var config models.Config
err := c.BindJSON(&config)
@@ -636,7 +636,7 @@ func VerifyPersistence(c *gin.Context) {
} else {
// Open test-480p.mp4
file, err := os.Open("./data/test-480p.mp4")
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
if err != nil {
msg := "VerifyPersistence: error reading test-480p.mp4: " + err.Error()
log.Log.Error(msg)
@@ -741,7 +741,7 @@ func VerifyPersistence(c *gin.Context) {
"_6-967003_" + config.Name + "_200-200-400-400_24_769.mp4"
// Open test-480p.mp4
file, err := os.Open("./data/test-480p.mp4")
file, err := os.Open(configDirectory + "/test-480p.mp4")
if err != nil {
msg := "VerifyPersistence: error reading test-480p.mp4: " + err.Error()
log.Log.Error(msg)
+4 -4
View File
@@ -20,14 +20,14 @@ import (
"go.mongodb.org/mongo-driver/bson"
)
func GetImageFromFilePath() (image.Image, error) {
snapshotDirectory := "./data/snapshots"
func GetImageFromFilePath(configDirectory string) (image.Image, error) {
snapshotDirectory := configDirectory + "/data/snapshots"
files, err := ioutil.ReadDir(snapshotDirectory)
if err == nil && len(files) > 1 {
sort.Slice(files, func(i, j int) bool {
return files[i].ModTime().Before(files[j].ModTime())
})
filePath := "./data/snapshots/" + files[1].Name()
filePath := configDirectory + "/data/snapshots/" + files[1].Name()
f, err := os.Open(filePath)
if err != nil {
return nil, err
@@ -44,7 +44,7 @@ func GetImageFromFilePath() (image.Image, error) {
// selected language, and if the installation was completed or not.
func ReadUserConfig(configDirectory string) (userConfig models.User) {
for {
jsonFile, err := os.Open("./data/config/user.json")
jsonFile, err := os.Open(configDirectory + "/data/config/user.json")
if err != nil {
log.Log.Error("Config file is not found " + configDirectory + "/data/config/user.json, trying again in 5s: " + err.Error())
time.Sleep(5 * time.Second)
+2 -2
View File
@@ -260,10 +260,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
}
// Handle recording, will write an mp4 to disk.
go capture.HandleRecordStream(queue, configuration, communication, streams)
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, streams)
// Handle Upload to cloud provider (Kerberos Hub, Kerberos Vault and others)
go cloud.HandleUpload(configuration, communication)
go cloud.HandleUpload(configDirectory, configuration, communication)
// Handle ONVIF actions
go onvif.HandleONVIFActions(configuration, communication)
+4 -4
View File
@@ -21,7 +21,7 @@ var Log = Logging{
var gologging = logging.MustGetLogger("gologger")
func ConfigureGoLogging(timezone *time.Location) {
func ConfigureGoLogging(configDirectory string, timezone *time.Location) {
// Logging
var format = logging.MustStringFormatter(
`%{color}%{time:15:04:05.000} %{shortfunc} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`,
@@ -32,7 +32,7 @@ func ConfigureGoLogging(timezone *time.Location) {
stdBackend := logging.NewLogBackend(os.Stderr, "", 0)
stdBackendLeveled := logging.NewBackendFormatter(stdBackend, format)
fileBackend := logging.NewLogBackend(&lumberjack.Logger{
Filename: "./data/log/machinery.txt",
Filename: configDirectory + "/data/log/machinery.txt",
MaxSize: 2, // megabytes
Compress: true, // disabled by default
}, "", 0)
@@ -75,10 +75,10 @@ type Logging struct {
Level string
}
func (self *Logging) Init(timezone *time.Location) {
func (self *Logging) Init(configDirectory string, timezone *time.Location) {
switch self.Logger {
case "go-logging":
ConfigureGoLogging(timezone)
ConfigureGoLogging(configDirectory, timezone)
case "logrus":
ConfigureLogrus(timezone)
default:
+5 -5
View File
@@ -78,7 +78,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
}
// The total number of recordings stored in the directory.
recordingDirectory := "./data/recordings"
recordingDirectory := configDirectory + "/data/recordings"
numberOfRecordings := utils.NumberOfMP4sInDirectory(recordingDirectory)
// All days stored in this agent.
@@ -115,7 +115,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
if eventFilter.NumberOfElements == 0 {
eventFilter.NumberOfElements = 10
}
recordingDirectory := "./data/recordings"
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
@@ -137,7 +137,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
})
api.GET("/days", func(c *gin.Context) {
recordingDirectory := "./data/recordings"
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
@@ -205,7 +205,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
})
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c)
cloud.VerifyPersistence(c, configDirectory)
})
// Streaming handler
@@ -215,7 +215,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
// We will only send an image once per second.
time.Sleep(time.Second * 1)
log.Log.Info("AddRoutes (/stream): reading from MJPEG stream")
img, err := components.GetImageFromFilePath()
img, err := components.GetImageFromFilePath(configDirectory)
return img, err
}
h := components.StartMotionJPEG(imageFunction, 80)
+12 -10
View File
@@ -60,9 +60,9 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
AddRoutes(r, authMiddleware, configDirectory, configuration, communication)
// Update environment variables
environmentVariables := "./www/env.js"
environmentVariables := configDirectory + "/www/env.js"
if os.Getenv("AGENT_MODE") == "demo" {
demoEnvironmentVariables := "./www/env.demo.js"
demoEnvironmentVariables := configDirectory + "/www/env.demo.js"
// Move demo environment variables to environment variables
err := os.Rename(demoEnvironmentVariables, environmentVariables)
if err != nil {
@@ -71,12 +71,14 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
}
// Add static routes to UI
r.Use(static.Serve("/", static.LocalFile("./www", true)))
r.Use(static.Serve("/dashboard", static.LocalFile("./www", true)))
r.Use(static.Serve("/media", static.LocalFile("./www", true)))
r.Use(static.Serve("/settings", static.LocalFile("./www", true)))
r.Use(static.Serve("/login", static.LocalFile("./www", true)))
r.Handle("GET", "/file/*filepath", Files)
r.Use(static.Serve("/", static.LocalFile(configDirectory+"/www", true)))
r.Use(static.Serve("/dashboard", static.LocalFile(configDirectory+"/www", true)))
r.Use(static.Serve("/media", static.LocalFile(configDirectory+"/www", true)))
r.Use(static.Serve("/settings", static.LocalFile(configDirectory+"/www", true)))
r.Use(static.Serve("/login", static.LocalFile(configDirectory+"/www", true)))
r.Handle("GET", "/file/*filepath", func(c *gin.Context) {
Files(c, configDirectory)
})
// Run the api on port
err = r.Run(":" + configuration.Port)
@@ -85,8 +87,8 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
}
}
func Files(c *gin.Context) {
func Files(c *gin.Context, configDirectory string) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Content-Type", "video/mp4")
c.File("./data/recordings" + c.Param("filepath"))
c.File(configDirectory + "/data/recordings" + c.Param("filepath"))
}
+6 -6
View File
@@ -110,15 +110,15 @@ func CountDigits(i int64) (count int) {
return count
}
func CheckDataDirectoryPermissions() error {
recordingsDirectory := "./data/recordings"
configDirectory := "./data/config"
snapshotsDirectory := "./data/snapshots"
cloudDirectory := "./data/cloud"
func CheckDataDirectoryPermissions(configDirectory string) error {
recordingsDirectory := configDirectory + "/data/recordings"
configurationDirectory := configDirectory + "/data/config"
snapshotsDirectory := configDirectory + "/data/snapshots"
cloudDirectory := configDirectory + "/data/cloud"
err := CheckDirectoryPermissions(recordingsDirectory)
if err == nil {
err = CheckDirectoryPermissions(configDirectory)
err = CheckDirectoryPermissions(configurationDirectory)
if err == nil {
err = CheckDirectoryPermissions(snapshotsDirectory)
if err == nil {
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh -e
cp -R $SNAP/data $SNAP_COMMON/
cp -R $SNAP/www $SNAP_COMMON/
cp -R $SNAP/version $SNAP_COMMON/
cp -R $SNAP/mp4fragment $SNAP_COMMON/
+23
View File
@@ -0,0 +1,23 @@
name: kerberosio # you probably want to 'snapcraft register <name>'
base: core22 # the base snap is the execution environment for this snap
version: '3.0.0' # just for humans, typically '1.2+git' or '1.3.2'
summary: A stand-alone open source video surveillance system # 79 char long summary
description: |
Kerberos Agent is an isolated and scalable video (surveillance) management
agent made available as Open Source under the MIT License. This means that
all the source code is available for you or your company, and you can use,
transform and distribute the source code; as long you keep a reference of
the original license. Kerberos Agent can be used for commercial usage.
grade: stable # stable # must be 'stable' to release into candidate/stable channels
confinement: strict # use 'strict' once you have the right plugs and slots
environment:
GIN_MODE: release
apps:
agent:
command: main -config /var/snap/kerberosio/common
plugs: [ network, network-bind ]
parts:
agent:
source: . #https://github.com/kerberos-io/agent/releases/download/21c0e01/agent-amd64.tar
plugin: dump
+3 -3
View File
@@ -9,9 +9,9 @@ const dev = {
ENV: 'dev',
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
HOSTNAME: hostname,
API_URL: `${protocol}//${hostname}:8080/api`,
URL: `${protocol}//${hostname}:8080`,
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
API_URL: `${protocol}//${hostname}:80/api`,
URL: `${protocol}//${hostname}:80`,
WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
MODE: window['env']['mode'],
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
// HOSTNAME: externalHost,