From f76004a2617562b34f59f32c3f5e4f4857c743af Mon Sep 17 00:00:00 2001 From: Adam Veldhousen Date: Thu, 13 May 2021 10:54:52 -0500 Subject: [PATCH] some style tweaks to the frontend, added search bar, fixed bug with errors not showing correctly --- .gitignore | 7 +- client/.gitignore | 6 ++ client/README.md | 10 +++ client/package.json | 41 ++++++++++ client/public/favicon.png | Bin 0 -> 3127 bytes client/public/index.html | 16 ++++ client/rollup.config.js | 74 +++++++++++++++++ client/src/App.svelte | 21 +++++ client/src/Tailwind.svelte | 14 ++++ client/src/api/logs.ts | 25 ++++++ client/src/api/metrics.ts | 62 ++++++++++++++ client/src/components/DatetimePicker.svelte | 17 ++++ client/src/components/LogViewer.svelte | 52 ++++++++++++ client/src/components/SearchOptions.svelte | 85 ++++++++++++++++++++ client/src/components/TimeChart.svelte | 66 +++++++++++++++ client/src/main.ts | 7 ++ client/src/routes/Home.svelte | 20 +++++ client/tailwind.config.js | 24 ++++++ client/tsconfig.json | 6 ++ internal/dnsHandler.go | 9 ++- internal/sqlite.go | 47 +++++++---- makefile | 8 +- 22 files changed, 596 insertions(+), 21 deletions(-) create mode 100644 client/.gitignore create mode 100644 client/README.md create mode 100644 client/package.json create mode 100644 client/public/favicon.png create mode 100644 client/public/index.html create mode 100644 client/rollup.config.js create mode 100644 client/src/App.svelte create mode 100644 client/src/Tailwind.svelte create mode 100644 client/src/api/logs.ts create mode 100644 client/src/api/metrics.ts create mode 100644 client/src/components/DatetimePicker.svelte create mode 100644 client/src/components/LogViewer.svelte create mode 100644 client/src/components/SearchOptions.svelte create mode 100644 client/src/components/TimeChart.svelte create mode 100644 client/src/main.ts create mode 100644 client/src/routes/Home.svelte create mode 100644 client/tailwind.config.js create mode 100644 client/tsconfig.json diff --git a/.gitignore b/.gitignore index 3657509..e22d33e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ .bin -.idea \ No newline at end of file +.idea + +client/node_modules/ +client/public/build/ + +.DS_Store diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..b679f81 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,6 @@ +/node_modules/ +/public/build/ +.vscode/ +.DS_Store +package-lock.json +yarn.lock diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..6bc6192 --- /dev/null +++ b/client/README.md @@ -0,0 +1,10 @@ +# Svelte TypeScript Tailwindcss Setup + +svelte template based on the default svelte/template with enabled typescript and tailwindcss support. + +```bash +npx degit munxar/svelte-template my-svelte-project +cd my-svelte-project +npm i +npm run dev +``` diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..0e170a2 --- /dev/null +++ b/client/package.json @@ -0,0 +1,41 @@ +{ + "name": "svelte-app", + "version": "1.0.0", + "scripts": { + "build": "NODE_ENV=production rollup -c", + "dev": "rollup -c -w", + "start": "sirv public", + "validate": "svelte-check" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^17.0.0", + "@rollup/plugin-node-resolve": "^11.0.0", + "@rollup/plugin-typescript": "^8.1.0", + "@tsconfig/svelte": "^1.0.0", + "autoprefixer": "^10.2.3", + "postcss": "^8.2.4", + "rollup": "^2.3.4", + "rollup-plugin-css-only": "^3.1.0", + "rollup-plugin-livereload": "^2.0.0", + "rollup-plugin-svelte": "^7.0.0", + "rollup-plugin-terser": "^7.0.0", + "svelte": "^3.38.2", + "svelte-check": "^1.0.0", + "svelte-preprocess": "^4.6.3", + "tailwindcss": "^2.0.2", + "tslib": "^2.0.0", + "typescript": "^4.1.3" + }, + "dependencies": { + "@fortawesome/free-solid-svg-icons": "^5.15.3", + "bootstrap": "^5.0.0", + "chart.js": "^3.2.1", + "date-fns": "^2.21.3", + "fa-svelte": "^3.1.0", + "normalize.css": "^8.0.1", + "sirv-cli": "^1.0.0", + "svelte-chartjs": "^1.0.1", + "svelte-routing": "^1.6.0", + "sveltestrap": "^4.2.1" + } +} diff --git a/client/public/favicon.png b/client/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..7e6f5eb5a2f1f1c882d265cf479de25caa925645 GIT binary patch literal 3127 zcmV-749N3|P)i z7)}s4L53SJCkR}iVi00SFk;`MXX*#X*kkwKs@nFGS}c;=?XFjU|G$3t^5sjIVS2G+ zw)WGF83CpoGXhLGW(1gW%uV|X7>1P6VhCX=Ux)Lb!*DZ%@I3!{Gsf7d?gtIQ%nQiK z3%(LUSkBji;C5Rfgd6$VsF@H`Pk@xtY6t<>FNR-pD}=C~$?)9pdm3XZ36N5PNWYjb z$xd$yNQR9N!dfj-Vd@BwQo^FIIWPPmT&sZyQ$v81(sCBV=PGy{0wltEjB%~h157*t zvbe_!{=I_783x!0t1-r#-d{Y?ae$Q4N_Nd^Ui^@y(%)Gjou6y<3^XJdu{rmUf-Me?)zZ>9OR&6U5H*cK; z$gUlB{g0O4gN0sLSO|Of?hU(l?;h(jA3uH!Z{EBKuV23ouU@^Y6#%v+QG;>e*E}%?wlu-NT4DG zs)z)7WbLr)vGAu(ohrKc^em@OpO&f~6_>E61n_e0_V3@{U3^O;j{`^mNCJUj_>;7v zsMs6Hu3g7+@v+lSo;=yTYFqq}jZmQ-BK8K{C4kqi_i*jBaQE(Au0607V-zKeT;EPg zX(`vrn=L+e74+-Tqeok@_`tDa$G9I|$nTU5H*2V8@y()n*zqM?J1G!-1aX;CfDC9B zTnJ#j_%*n8Qb1)re*Bno7g0RG{Eb;IK14irJYJp$5Z6ac9~b_P?+5t~95~SRG$g?1 znFJ7p$xV&GZ18m~79TGRdfsc-BcX$9yXTR*n)mPD@1~O(_?cT$ZvFPucRmGlq&se0 zKrcUf^k}4hM*biEJOWKzz!qQe;CB_ZtSOO9Owg#lZAc=s65^rb{fZe(TYu_rk!wKkEf}RIt=#Om( zR8mN`DM<^xj~59euMMspBolVN zAPTr8sSDI104orIAdmL$uOXn*6hga1G+0WD0E?UtabxC#VC~vf3|10|phW;yQ3CY8 z2CM=)ErF;xq-YJ5G|um}>*1#E+O_Mu|Nr#qQ&G1P-NMq@f?@*XUcSbV?tX=)ilM-Q zBZP|!Bpv0V;#ojKcpc7$=eqO;#Uy~#?^kNI{vSZfLx&DEt~LTmaKWXcx=joubklI<*Aw z>LtMaQ7DR<1I2LkWvwyu#Rwn~;ezT}_g(@5l3h?W%-a86Y-t#O1PubP+z<%?V5D(U zy57A6{h+{?kOZp7&WKZR+=sznMJ}+Dnpo=C_0%R_x_t~J5T?E_{+))l5v1%52>)d-`iiZyx|5!%M2Fb2dU zW3~MwwpEH9Rhue+k$UIOoo($Ds!NbOyMR36fRHu;*15(YcA7siIZk#%JWz>P!qX1?IUojG&nKR>^gArBt2 zit(ETyZ=@V&7mv_Fi4bABcnwP+jzQuHcfU&BrAV91u-rFvEi7y-KnWsvHH=d2 zgAk(GKm_S8RcTJ>2N3~&Hbwp{Z3NF_Xeh}g4Eke)V&dY{W(3&b1j9t4yK_aYJisZZ{1rcU5- z;eD>K;ndPq&B-8yA_S0F!4ThA&{1{x)H<#?k9a#6Pc6L?V^s0``ynL&D;p(!Nmx`Y zFkHex{4p!Ggm^@DlehW}iHHVi}~u=$&N? z(NEBLQ#UxxAkdW>X9LnqUr#t4Lu0=9L8&o>JsqTtT5|%gb3QA~hr0pED71+iFFr)dZ=Q=E6ng{NE{Z~0)C?deO#?Aj zSDQ$z#TeC2T^|=}6GBo-&$;E{HL3!q3Z-szuf)O=G#zDjin4SSP%o%6+2IT#sLjQa ziyxFFz~LMjWY+_a5H!U6%a<=b7QVP^ z*90a62;bVq{?@)P6^DWd^Yilq4|YTV2Nw!Yu;a1lPI-sxR)rf@Fe5DhDP7FH zZZ%4S*1C30P;|O+jB!1;m|rXT90Sm5*RBbQN`PKu+hDD*S^yE(CdtSfg=z>u$cIj> z + + + + + + Svelte app + + + + + + + + + diff --git a/client/rollup.config.js b/client/rollup.config.js new file mode 100644 index 0000000..8bb6116 --- /dev/null +++ b/client/rollup.config.js @@ -0,0 +1,74 @@ +import svelte from "rollup-plugin-svelte"; +import commonjs from "@rollup/plugin-commonjs"; +import resolve from "@rollup/plugin-node-resolve"; +import livereload from "rollup-plugin-livereload"; +import { terser } from "rollup-plugin-terser"; +import sveltePreprocess from "svelte-preprocess"; +import typescript from "@rollup/plugin-typescript"; +import css from "rollup-plugin-css-only"; + +const production = !process.env.ROLLUP_WATCH; + +function serve() { + let server; + + function toExit() { + if (server) server.kill(0); + } + + return { + writeBundle() { + if (server) return; + server = require("child_process").spawn( + "npm", + ["run", "start", "--", "--dev"], + { + stdio: ["ignore", "inherit", "inherit"], + shell: true, + } + ); + + process.on("SIGTERM", toExit); + process.on("exit", toExit); + }, + }; +} + +export default { + input: "src/main.ts", + output: { + sourcemap: true, + format: "iife", + name: "app", + file: "public/build/bundle.js", + }, + plugins: [ + svelte({ + // add postcss config with tailwind + preprocess: sveltePreprocess({ + postcss: { + plugins: [require("tailwindcss"), require("autoprefixer")], + }, + }), + compilerOptions: { + dev: !production, + }, + }), + css({ output: "bundle.css" }), + resolve({ + browser: true, + dedupe: ["svelte"], + }), + commonjs(), + typescript({ + sourceMap: !production, + inlineSources: !production, + }), + !production && serve(), + !production && livereload("public"), + production && terser(), + ], + watch: { + clearScreen: false, + }, +}; diff --git a/client/src/App.svelte b/client/src/App.svelte new file mode 100644 index 0000000..650db01 --- /dev/null +++ b/client/src/App.svelte @@ -0,0 +1,21 @@ + + + +
+ + +
+ +
+
+
diff --git a/client/src/Tailwind.svelte b/client/src/Tailwind.svelte new file mode 100644 index 0000000..b45f6e8 --- /dev/null +++ b/client/src/Tailwind.svelte @@ -0,0 +1,14 @@ + diff --git a/client/src/api/logs.ts b/client/src/api/logs.ts new file mode 100644 index 0000000..fc9f598 --- /dev/null +++ b/client/src/api/logs.ts @@ -0,0 +1,25 @@ +import { sub } from 'date-fns' +import { formatDate } from './metrics'; + + +export const getLogs = async function ({ + start = sub(new Date(), { hours: 12 }), + end = new Date(), + filter = "" +} = {}) { + try { + const data = await fetch(`http://localhost:8080/api/v1/metrics/log?filter=${filter}`, { + "method": "GET", + "headers": { "Accept": "application/json" } + }); + + const { success, payload } = await data.json(); + if (success) { + return { payload, start, end, filter }; + } + return { error: payload }; + } catch (error) { + console.error(error); + return { error }; + } +} diff --git a/client/src/api/metrics.ts b/client/src/api/metrics.ts new file mode 100644 index 0000000..42a1726 --- /dev/null +++ b/client/src/api/metrics.ts @@ -0,0 +1,62 @@ +import { sub, format } from 'date-fns'; +import { readable } from "svelte/store"; + +export const formatDate = date => format(date, "yyyy-MM-dd HH:mm:ss.SSS"); + +/* + take something like `clientIp:127.0.0.1 protocol:udp domain:google.com` + `(prop:expr)* +*/ +export const parseTerms = (terms: string = "") :[string] | [] => { + + const matches = terms.match(/([a-z]+[:>][\w\.]+)*/ig); + + const exprs = matches.reduce((agg, term) => { + + }, []); + return []; +}; + +export const fetchMetrics = async function ({ + start = sub(new Date(), { hours: 8 }), + end = new Date(), + key = "domain", + interval = "30", +} = {}) { + try { + const response = await fetch( + `http://localhost:8080/api/v1/metrics/stats?start=${formatDate( + start + )}&end=${formatDate(end)}&key=${key}&interval=${interval}`, + { + method: "GET", + headers: { Accept: "application/json" } + } + ); + + const { success, payload } = await response.json(); + + if (!success) { + return { error: payload }; + } + + return { payload, interval, start, end, key }; + } catch (e) { + console.error(e); + return { error: e }; + } +}; + +export const metricsStore = readable([], (set) => { + const interval = setInterval(async () => { + const { error, payload } = await fetchMetrics({}); + if (error) { + console.error(error); + return; + } + + console.log(payload); + set(payload); + }, 15 * 1000); + return () => clearInterval(interval); + }); diff --git a/client/src/components/DatetimePicker.svelte b/client/src/components/DatetimePicker.svelte new file mode 100644 index 0000000..c54593b --- /dev/null +++ b/client/src/components/DatetimePicker.svelte @@ -0,0 +1,17 @@ + + + + + {label} + + + + diff --git a/client/src/components/LogViewer.svelte b/client/src/components/LogViewer.svelte new file mode 100644 index 0000000..7d13498 --- /dev/null +++ b/client/src/components/LogViewer.svelte @@ -0,0 +1,52 @@ + + +
+ {#if rows.length > 0} + + + {row.Started} + + + {row.Domain} + + + {row.ClientIP} + + + {row.Status} + + + {row.Protocol} + + + {#if row.Error} + {row.Error} + {/if} + + + {row.RecurseRoundTripTimeMs} + + + {row.RecurseUpstreamIP} + + + {row.TotalTimeMs} + +
+ {/if} +
diff --git a/client/src/components/SearchOptions.svelte b/client/src/components/SearchOptions.svelte new file mode 100644 index 0000000..2782e8f --- /dev/null +++ b/client/src/components/SearchOptions.svelte @@ -0,0 +1,85 @@ + + +
+
+ + + + Search + + + + + + + + + + + By {selected.label} + + {#each menuItem as item} + + selectItem(item)}> + {item.label} + + + {/each} + + + +
+
diff --git a/client/src/components/TimeChart.svelte b/client/src/components/TimeChart.svelte new file mode 100644 index 0000000..cedac97 --- /dev/null +++ b/client/src/components/TimeChart.svelte @@ -0,0 +1,66 @@ + + +
+ +
diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..83bc6ab --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,7 @@ +import App from "./App.svelte"; + +const app = new App({ + target: document.body, +}); + +export default app; diff --git a/client/src/routes/Home.svelte b/client/src/routes/Home.svelte new file mode 100644 index 0000000..c341567 --- /dev/null +++ b/client/src/routes/Home.svelte @@ -0,0 +1,20 @@ + + +
+
+

Home

+
+
+ +
+
+ +
+
+ +
+
diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 0000000..a5386f1 --- /dev/null +++ b/client/tailwind.config.js @@ -0,0 +1,24 @@ +const { tailwindExtractor } = require("tailwindcss/lib/lib/purgeUnusedStyles"); + +module.exports = { + purge: { + content: ["src/**/*.svelte", "public/index.html"], + options: { + defaultExtractor: (content) => [ + ...tailwindExtractor(content), + ...[...content.matchAll(/(?:class:)*([\w\d-/:%.]+)/gm)].map( + ([_match, group, ..._rest]) => group + ), + ], + keyframes: true, + }, + }, + darkMode: false, // or 'media' or 'class' + theme: { + extend: {}, + }, + variants: { + extend: {}, + }, + plugins: [], +}; diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..b082e96 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + + "include": ["src/**/*"], + "exclude": ["node_modules/*", "__sapper__/*", "public/*"] +} \ No newline at end of file diff --git a/internal/dnsHandler.go b/internal/dnsHandler.go index 52f79ee..2c3b61e 100644 --- a/internal/dnsHandler.go +++ b/internal/dnsHandler.go @@ -29,6 +29,7 @@ func (dm *DomainManager) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { } var resolved Resolved + var err error // lookup in cache if dest := dm.LookupRecord(q.Name); dest != nil { responseMessage = new(dns.Msg) @@ -39,7 +40,7 @@ func (dm *DomainManager) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { responseMessage = rule.CreateAnswer(q.Name) responseMessage.Authoritative = true ql.Status = CustomRule - } else if resolved, ql.Error = dm.Recursors.Resolve(r); ql.Error == nil { + } else if resolved, err = dm.Recursors.Resolve(r); err == nil { dm.SaveAnswers(q.Name, resolved.Message.Answer) responseMessage = resolved.Message ql.RecurseUpstreamIP = resolved.UpstreamUsed @@ -47,6 +48,10 @@ func (dm *DomainManager) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { ql.Status = RecursedUpstream } + if err != nil { + ql.Error = err.Error() + } + responseMessage.SetReply(r) responseMessage.RecursionAvailable = true responseMessage.Compress = true @@ -82,6 +87,6 @@ type QueryLog struct { TotalTimeMs int RecurseRoundTripTimeMs int RecurseUpstreamIP string - Error error + Error string Status ResponseStatus } diff --git a/internal/sqlite.go b/internal/sqlite.go index 1a46c29..719e096 100644 --- a/internal/sqlite.go +++ b/internal/sqlite.go @@ -2,7 +2,6 @@ package internal import ( "database/sql" - "errors" "fmt" "io" "net" @@ -103,7 +102,7 @@ type RuleRow struct { } func (ss *Sqlite) AddRule(rr RuleRow) error { - sql := `INSERT INTO rules (name, expression, answerType, answerValue, ttl, weight, enabled, created) + sql := `INSERT INTO rules (name, expression, answerType, answerValue, ttl, weight, enabled, created) VALUES (?, ? , ?, ?, ?, ?, 1, ?);` if _, err := ss.Exec(sql, rr.Name, rr.Value, rr.Answer.Type, rr.Answer.Value, rr.TTL, rr.Weight, time.Now().UTC().Format(ISO8601)); err != nil { @@ -194,12 +193,12 @@ func (ss *Sqlite) GetLog(in GetLogInput) ([]QueryLog, error) { } sql := ` - SELECT + SELECT started, clientIp, protocol, domain, totalTimeMs, error, recurseRoundTripTimeMs, recurseUpstreamIp, status FROM log WHERE - id > ? AND started > ? AND started < ? + id > ? AND started > ? AND started < ? ORDER BY started DESC LIMIT ?; ` @@ -213,7 +212,6 @@ func (ss *Sqlite) GetLog(in GetLogInput) ([]QueryLog, error) { var ql []QueryLog for rows.Next() { var q QueryLog - var errStr string var started string rows.Scan( @@ -222,16 +220,12 @@ func (ss *Sqlite) GetLog(in GetLogInput) ([]QueryLog, error) { &q.Protocol, &q.Domain, &q.TotalTimeMs, - &errStr, + &q.Error, &q.RecurseRoundTripTimeMs, &q.RecurseUpstreamIP, &q.Status, ) - if errStr != "" { - q.Error = errors.New(errStr) - } - if q.Started, err = time.Parse(ISO8601, started); err != nil { return nil, fmt.Errorf("could not parse time '%s': %v", started, err) } @@ -294,7 +288,7 @@ func (ss *Sqlite) GetLogAggregate(la LogAggregateInput) ([]LogAggregateDataPoint strftime('%%s', started)/(%d) as "timeWindow" FROM log GROUP BY %s, strftime('%%s', started) / (%d) - ORDER BY started desc; + ORDER BY started ASC; ` sql = fmt.Sprintf(sql, column, timeWindow, column, timeWindow) @@ -328,22 +322,18 @@ func (ss *Sqlite) GetLogAggregate(la LogAggregateInput) ([]LogAggregateDataPoint func (ss *Sqlite) Log(ql QueryLog) error { sql := ` - INSERT INTO log + INSERT INTO log (started, clientIp, protocol, domain, totalTimeMs, error, recurseRoundTripTimeMs, recurseUpstreamIp, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); ` - var errStr string - if ql.Error != nil { - errStr = ql.Error.Error() - } if _, err := ss.DB.Exec(sql, ql.Started.Format(ISO8601), ql.ClientIP, ql.Protocol, ql.Domain, ql.TotalTimeMs, - errStr, + ql.Error, ql.RecurseRoundTripTimeMs, ql.RecurseUpstreamIP, ql.Status, @@ -386,6 +376,7 @@ func initTable(db *sql.DB) error { status TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS rules ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, @@ -400,6 +391,7 @@ func initTable(db *sql.DB) error { CREATE UNIQUE INDEX IF NOT EXISTS idx_rules_name ON rules (name); CREATE UNIQUE INDEX IF NOT EXISTS idx_rules_expression ON rules (expression); + CREATE TABLE IF NOT EXISTS recursors ( id INTEGER PRIMARY KEY, ipAddress TEXT NOT NULL, @@ -407,6 +399,27 @@ func initTable(db *sql.DB) error { weight INT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_recursors_ipAddress ON recursors (ipAddress); + + + CREATE TABLE IF NOT EXISTS ruleslist ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + lastLoadedTs TEXT NOT NULL, + weight INT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ruleslist_name ON ruleslist (name); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ruleslist_url ON ruleslist (url); + + + CREATE TABLE IF NOT EXISTS ruleslist_entry ( + id INTEGER PRIMARY KEY, + ruleslistId INTEGER NOT NULL, + expression TEXT NOT NULL, + ipAddress TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ruleslist_entry_expression ON ruleslist_entry (expression); + CREATE UNIQUE INDEX IF NOT EXISTS idx_ruleslist_entry_ruleslistId ON ruleslist_entry (id, ruleslistId); ` if _, err := db.Exec(sql); err != nil { diff --git a/makefile b/makefile index c888a6e..57a9a9f 100644 --- a/makefile +++ b/makefile @@ -1,11 +1,17 @@ dev: clean .bin/gopherhole .bin/config.json cd .bin && ./gopherhole -config config.json +client-dev: client/node_modules + cd ./client && npm run dev + +client/node_modules: + cd ./client && npm install + clean: @rm -rf .bin/gopherhole clobber: - @rm -rf .bin + @rm -rf .bin ./client/node_modules test: dig -p 5353 twitter.com @localhost