`) and of a context (the context's
+ JNDI environment) exposes its JNDI entries โ `resource`,
+ `resourceLink` (context only; not part of the global environment),
+ `resourceEnvRef`, `environment`, `ejb`, `localEjb` and `serviceRef` โ
+ each keyed by its JNDI name. The `namingResources` node itself is
+ required and cannot be removed (400 `REQUIRED_COMPONENT`); the
+ entries are added/removed and the node's `global` flag drives the
+ client (a server node offers no `resourceLink`). Adding an entry
+ registers and binds it in the live JNDI environment at once (the
+ `NamingContextListener` reacts to the add); removing one unbinds it.
+ An entry's attributes are edited through the property list. Every
+ entry type extends `ResourceBase`, which carries a generic map of
+ string parameters (the RefAddr keys the JNDI factories consume at
+ lookup time); those parameters are shown in the entry detail for
+ **all** the entry types and can be added (an "Add parameter" form),
+ edited, or removed (cleared) from the property list. For a
+ `resource` the closed option set (RefAddr keys) of its effective
+ first-party factory is additionally rendered as typed fields โ the
+ four shipped factories (`BasicDataSourceFactory`,
+ `MemoryUserDatabaseFactory`, `DataSourceUserDatabaseFactory`, and the
+ `PerUserPool`/`SharedPool` data sources) render their options as
+ typed fields, while any other factory (or an extra key) is a
+ free-form parameter. Editing an attribute or a `param` re-binds the
+ entry by removing and re-adding it (so the change is visible to the
+ live JNDI environment without a restart); renaming a JNDI name is a
+ risky change (type-to-confirm).
+ - *Remove* asks for typing the component's display name and is
+ disabled for the self component. Server-side guards additionally
+ reject: the last service (`LAST_SERVICE`), the service/engine/host/
+ context hosting this webapp (`SELF_COMPONENT`), the basic (first)
+ valve of a pipeline (`BASIC_COMPONENT`), an engine that still
+ contains hosts (`NOT_EMPTY`), a directly attached realm whose
+ container would be left without a realm (`LAST_REALM`), a context's
+ `manager`/`resources`/`loader`/`cookieProcessor` or a manager's
+ `sessionIdGenerator` (`REQUIRED_COMPONENT` โ required, replace
+ instead), the default
+ SSL host configuration of a running, TLS enabled connector while
+ other configurations remain (`SSL_DEFAULT`) and the last
+ certificate of a running, TLS enabled connector (`SSL_LAST_CERTIFICATE`).
+ - *Save to server.xml* fetches the preview (the resulting `server.xml`
+ plus the external context files that would be rewritten, shown in a
+ wide modal) and, on confirmation (typing `server.xml`), persists the
+ live state: a timestamped backup of the previous `server.xml` is
+ kept. Contexts keep their current storage location, mirroring regular
+ StoreConfig behaviour โ a context backed by its own file
+ (`META-INF/context.xml` or `conf/Catalina/.../context.xml`) is written
+ back to that file, a context defined inline stays inline in
+ `server.xml`. The preview is read-only (external files are captured in
+ memory, never written). A context restarts when its own file is
+ written, so a save that rewrites this webapp's own file restarts the
+ manager and resets the admin session; the dialog warns about this when
+ it applies (`restartsManager` in the preview response).
+
+**Users**
+- Manages the users, groups and roles of the `UserDatabase` JNDI resources
+ configured for the server (the default `server.xml` configures the file
+ based `MemoryUserDatabase` that backs `conf/tomcat-users.xml`). Databases
+ are discovered through the server's global JNDI naming context; when more
+ than one is configured a selector in the header chooses which to operate
+ on (all API calls accept a `name` field/parameter).
+- Header card: database type, `read-only` / `not writable` / `writable`
+ badge, and the JNDI name (plus id).
+- When the database is read-only a warning banner explains that
+ `readonly="false"` must be set on its `Resource` in `server.xml`, and all
+ mutation controls are disabled.
+- Users table: user name (+ full name), roles (badges; roles inherited
+ through groups shown dimmed with a `+`), groups, row actions *Roles* /
+ *Password* / *Remove* (remove asks for typing the user name). "Add user"
+ modal: user name, password, full name, roles (comma separated with
+ suggestions).
+- Groups table: group name, roles, members, row actions *Members* / *Roles* /
+ *Remove*. "Add group" modal: group name, description, roles.
+- Roles table: role name, description, the users and groups that hold the
+ role, row action *Remove* (removal asks for typing the role name and
+ detaches the role from all users and groups). "Add role" modal: role name,
+ description. Roles can also still be created implicitly when assigned to a
+ user or group; removing the last user or group that uses a role leaves the
+ (now unused) role defined, matching `UserDatabase` semantics.
+- Security notes: passwords are stored exactly as provided (same semantics as
+ the `password` attribute of `tomcat-users.xml`) and are never returned by
+ the API; the signed-in account cannot remove itself, nor a role it holds
+ (directly or through a group); group members must
+ exist; name values are restricted to a safe character set because they are
+ persisted in comma separated lists and used as URL path segments. Every
+ mutation is followed by `save()` so the change is persisted to the storage
+ of the database.
+
+**Monitoring**
+- CPU and memory status cards (5 s cadence): an instant snapshot from
+ `GET /api/status/system`, not a chart. The CPU card shows the system and
+ JVM process CPU load as percentage bars, plus cores, 1-minute load
+ average and live/daemon/peak thread counts; the memory card shows
+ physical memory and swap usage, the JVM heap (used / max bar, committed)
+ and non-heap usage as bars and facts, and a per-memory-pool table
+ (used, committed, max). Values the platform does not expose (CPU loads
+ before the first monitoring interval, the load average on Windows,
+ physical/swap memory on a JVM without the HotSpot management MBean)
+ render as "-".
+- Live workers table (5 s cadence): stage, processing time, bytes
+ sent/received, remote address (forwarded + actual), virtual host,
+ request line. Only *active* workers are listed โ stages P (parsing,
+ blue), S (service, green) and F (finishing, amber), each with its own
+ bullet colour; R (ready), K (keep-alive) and unknown stages are idle
+ one way or another and are left out so the table stays short; the
+ header badge reports "N active ยท X idle".
+- Connector detail cards with the same data as the Dashboard, plus
+ max-processing-time history chart.
+
+**Diagnostics**
+- SSL: ciphers / certs / trusted certs tables per connector; "reload SSL
+ host configs" (all, or single `tlsHostName`).
+- Memory leaks: "check now" button, list of suspect contexts.
+- JNDI resources: type filter, tree.
+- VM: VM info, thread dump (monospace viewer, download as .txt).
+
+**Logs**
+- File selector over the JULI server log files (`catalina`, `localhost`,
+ `manager`, `host-manager`, `catalina.out`), max-lines selector (500-5000),
+ refresh and download buttons (the download saves the full, unfiltered raw
+ file of the current selection).
+- Filters: severity (the levels actually present in the file) and free-text
+ search.
+- Table: time, level (coloured badge), thread, source, message. Row click
+ opens a drawer with the full record, including the stack trace.
+- Both the plain one-line format and the JSON log format
+ (`org.apache.juli.JsonFormatter`) are handled; the format is detected per
+ file from the first line.
+
+**Access log**
+- File selector over the access log files, max-lines selector, refresh and
+ download buttons (the download saves the full, unfiltered raw file of the
+ current selection).
+- Filters that are shown depend on the fields the configured format
+ provides: method, status (class `1xx`-`5xx` or exact code), user, session
+ ID and free-text search. A filter is only offered when its field is part
+ of the configured format (e.g. no session-ID filter when the pattern has
+ no `%S`).
+- The pattern based format is parsed with the pattern of the configured
+ `AccessLogValve` (falling back to the common and combined patterns when no
+ valve is configured), and the JSON format
+ (`org.apache.catalina.valves.JsonAccessLogValve`) as one JSON object per
+ line. Both formats expose the same field names, and the method / path /
+ query / protocol are derived from the request line when only `%r` is
+ logged.
+- Table: host, user, time, the method / path / query / protocol columns
+ (derived from `%r` when the format logs only the request line), status
+ (coloured badge), size, session ID (when logged). The raw request line
+ is not a column โ it repeats the derived columns and is by far the
+ widest; it stays available in the record drawer, which a row click
+ opens with the full record.
+
+### 5.3 Design system
+
+- Plain CSS with custom properties (no framework); light + dark themes
+ (system preference default, manual toggle, persisted in
+ `localStorage`).
+- System font stack; 12-column grid; 8 px spacing scale; single accent
+ colour; states (success/warning/danger) with colour + icon (never colour
+ alone). The accent is otherwise neutral: per-tab hues are reserved for
+ the active navigation item (see ยง5.1) so the current section is
+ recognisable at a glance, and are never used for content.
+- Responsive. Phone mode below 900 px (bottom tab bar, management tables
+ (applications,
+ hosts, users, sessions, connectors, servlets, โฆ) convert to stacked
+ cards, each cell labelled with its column header; kebab menus, full-width
+ forms and modals) โ 900 px rather than the classic 768 px so iPad
+ portrait and small tablets get it too. Independently, high-volume tables
+ (server logs, access log, active workers) get the scrollable treatment โ
+ size to content so columns are never squeezed or wrapped, first column
+ pinned while the table scrolls horizontally โ up to 1150 px, because
+ below that even the thirteen access-log columns do not fit without
+ squeezing and a horizontal scroll is the better trade; above it the
+ ordinary desktop tables take over (which also keeps ordinary desktop
+ windows, however short, on the desktop treatment). The log pages never
+ pin their first column at any width in that range โ the pinned Time
+ column would dominate the viewport and leave a sliver for the scrolling
+ content, so the whole log table scrolls.
+ The log pages' long text columns (messages) are given most of
+ the viewport (90vw portrait, 100vw landscape) because with
+ `table-layout:auto` any extra table width flows into the only wrapping
+ column, keeping rows to one or two lines for information density.
+ Charts re-flow to a single column; row actions move into an overflow
+ (kebab) menu; form fields and modals go full width.
+- The side navigation is an icons-only 60 px rail at all widths where it
+ is shown; in landscape viewports it pops out at full width and label on
+ hover/focus as a fixed flyout that never resizes the view (see ยง5.1).
+- Accessibility (WCAG 2.1 AA): semantic landmarks, visible focus states,
+ keyboard-operable modals/drawers (focus trap, `Esc` closes),
+ `aria-live="polite"` toasts, live chart updates announced at reduced
+ frequency, contrast โฅ 4.5:1.
+
+## 6. Live updating
+
+- **Default transport: HTTP polling.** Dashboard 2 s, Monitoring 5 s,
+ Applications list 10 s; all polling pauses when `document.hidden` and
+ resumes on focus. Intervals are servlet init-params
+ (`pollIntervalStatus`, โฆ) and client-configurable.
+- **Why not WebSockets/SSE first:** polling is proxy/LoadBalancer-safe,
+ stateless, and the payload is tiny; the MBean attribute reads done per
+ poll are the same ones the legacy status page did per full page load.
+ A `GET /api/stream` (Server-Sent Events) endpoint is specified as an
+ optional future extension behind an init-param.
+- **Charting:** hand-written canvas module (`js/charts.js`): rolling
+ ring-buffer per series, auto-scaling y-axis, 1 s/5 s/15 min window
+ switcher, hover readout, optional log scale for byte counters. Line
+ charts, area charts and gauges only โ no external chart library.
+- **Data integrity:** every snapshot carries `ts`; the client discards
+ out-of-order duplicates and renders a gap marker if a poll is missed.
+ Counter deltas that go backwards (JVM/connector restart) reset the rate
+ calculation instead of producing negative rates.
+
+## 7. Security
+
+### 7.1 Authentication โ HTTP FORM
+
+`WEB-INF/web.xml` (replacing the legacy `BASIC` login-config):
+
+```xml
+
+ FORM
+ Tomcat Manager2 Application
+
+ /login
+ /login?error=1
+
+
+```
+
+- Works with any configured Realm (default file realm, JDBC, LDAP, โฆ);
+ credentials are sent once over the session, not in every request header.
+- The login page is rendered by `LoginServlet` (not served as a static
+ resource) for three reasons:
+ - it injects a `` element, because the page can be displayed at
+ arbitrary URLs (the browser keeps the URL of the request that triggered
+ the forward) and the relative CSS link would otherwise break;
+ - it normalizes the FORM-authentication *saved request* so the
+ post-login redirect lands inside the application instead of at the
+ last unauthenticated request, which for a single-page application is
+ frequently a CSS or JS file or a JSON API call. Concretely: when the
+ login page is being rendered for an SPA route (the deep-link gate in
+ `HomeServlet` forwards with the route URI still on the request), the
+ saved request is pointed at that route, so the user returns to exactly
+ the page they were on; any other saved request (an API call, an asset,
+ ...) is replaced with a GET of the context root (`/`). The root URL
+ (not `/index.html`) keeps the SPA router on its dashboard route;
+ `HomeServlet` serves the shell there.
+ - it creates the session (and records its ID, mirroring what the
+ authenticator does when it changes the session ID) so the login form
+ submission is tied to a session even when the browser arrived at the
+ login page without one.
+- `LoginServlet` also sets the `FormAuthenticator` landing page to the
+ context root (`/`) as a safety net for logins without a saved request.
+- The session cookie (`JSESSIONID`) is `HttpOnly`; deployments are
+ expected to serve manager2 over TLS so the cookie is `Secure`. The
+ context uses the RFC 6265 cookie processor with `sameSiteCookies="strict"`
+ (see ยง8).
+
+### 7.2 Authorization โ per-endpoint roles
+
+Same role names as the legacy webapps (no new roles to document; a
+`manager2-*` naming scheme is an open question, ยง12):
+
+```xml
+
+
+ Read-only status
+ /api/status
+ /api/status/*
+ /api/info
+ /api/csrf
+
+
+ manager-gui
+ manager-status
+
+
+
+
+
+ Manager API
+ /api/*
+
+
+ manager-gui
+
+
+```
+
+`manager-status` users get read-only monitoring; `manager-gui` users get
+everything.
+
+The SPA shell and its static assets (`/`, `/index.html`, `/css/*`, `/js/*`,
+`/img/*`, the login and error pages) are **not** protected with a security
+constraint. A constraint with the URL pattern `/` matches *every* request in
+the context (not just the context root), so protecting the shell with `/`
+would also catch the login page's own CSS and JS: they would be sent through
+FORM authentication (leaving the login page unstyled) and would poison the
+saved request (sending the browser to a CSS file after login). Instead:
+
+- `HomeServlet` gates the SPA entry point (`/`) and the SPA deep-link routes
+ (`/apps`, `/hosts`, `/configuration`, `/users`, `/monitoring`,
+ `/diagnostics`, `/logs`, `/access-log`, `/apps/*`): it forwards
+ unauthenticated visitors to the login page and authenticated users get the
+ shell rendered from `index.html` as a template, preserving the requested
+ URL so deep links survive a reload. The template rendering (rather than a
+ plain forward to the static file) injects a `` element (the
+ `` placeholder, see `Html`): a *multi-segment* deep
+ link such as `/apps/localhost/myapp` would otherwise make the browser
+ resolve the shell's relative asset URLs (`js/main.js`, `css/manager2.css`)
+ against the deep path (`/apps/localhost/js/main.js`), which the server
+ answers with the shell HTML and the browser then refuses as a script.
+- The context root *without* a trailing slash (e.g. `/manager2`) is
+ redirected (302) to the trailing-slash form by `HomeServlet`. Without the
+ redirect the browser would resolve the page's relative URLs (`css/*`,
+ `js/*`, `images/*`) against the server root instead of the context,
+ breaking the page. The mapper's own context-root redirect
+ (`Context#setMapperContextRootRedirectEnabled`, on by default) cannot do
+ this here: it only applies when *no* servlet is mapped to the context
+ root, but `HomeServlet` is (via the empty URL pattern, which the mapper
+ registers as the exact match `/`), so the mapper always finds a wrapper
+ before that redirect branch is reached.
+- The JSON API is the only place where data lives and the only place that is
+ constraint-protected. The SPA itself reacts to an unauthenticated API
+ response (the container forwards the XHR to the login page) by performing a
+ full navigation to the *current* URL (not the application root): the server
+ then gates that page to the login page at the same URL, and a successful
+ login returns the user to the page they were on. A short guard suppresses a
+ second bounce within a few seconds (falling back to the root) so a bad
+ credential cannot spin a reload loop. Note that error responses (4xx/5xx,
+ including the container's HTML error page) are surfaced to the page and are
+ *not* treated as an unauthenticated condition.
+
+ This matters when the session is lost while the user is mid-application
+ (e.g. the webapp is redeployed, which destroys all in-memory sessions): the
+ user is re-prompted for credentials at the page they were on rather than
+ being dropped at the root.
+
+### 7.3 CSRF protection
+
+Synchronizer-token pattern, implemented in a small `CsrfFilter`
+(`org.apache.tomcat.manager2.CsrfFilter`) mapped to `/api/*`:
+
+1. On first API access (or after login) the filter generates a 128-bit
+ `SecureRandom` token (32-char hex) and stores it in the `HttpSession`.
+2. The token is delivered to the SPA in the **`X-CSRF-Token` response
+ header of every API response** (so it is always fresh after a session
+ change) and also via `GET /api/csrf`. It is never placed in URLs or
+ query strings.
+3. Every non-safe request (`POST`, `PUT`, `DELETE`, `PATCH`) must carry the
+ token in an `X-CSRF-Token` request header. The SPA's `api.js` wrapper
+ reads the header from the last response and attaches it automatically;
+ missing or mismatched token โ `403` with `error: "CSRF"` and an audit
+ log entry.
+4. Defence in depth: all mutation endpoints require
+ `Content-Type: application/json` (except the multipart upload) and the
+ webapp sets no `Access-Control-Allow-Origin` headers, so a cross-origin
+ page cannot make a simple cross-site request succeed โ the browser
+ preflight is rejected. The token covers the cases browsers do not
+ (e.g. same-site subdomain hosting, non-browser clients that should be
+ rejected).
+5. The filter additionally requires an authenticated session for unsafe
+ methods (redundant with the security constraints, but it gives a clean
+ 403/401 split and a single audit point).
+
+This is functionally equivalent to the `CsrfPreventionFilter` the legacy
+webapp already applies to `/html/*`, but adapted to a header-based JSON
+client instead of a hidden form field.
+
+### 7.4 Security headers
+
+`HttpHeaderSecurityFilter` mapped to `/*`, with the new default
+`hstsEnabled=true` (overridable, like the legacy app but safer by
+default) plus an explicit CSP:
+
+```
+Content-Security-Policy: default-src 'self'; script-src 'self';
+ style-src 'self'; img-src 'self' data:; connect-src 'self';
+ frame-ancestors 'none'; base-uri 'self'; form-action 'self'
+X-Frame-Options: DENY
+X-Content-Type-Options: nosniff
+```
+
+No inline scripts/styles exist, so no `'unsafe-inline'` is needed.
+Note: `style-src 'self'` also makes the browser **drop style attributes
+set through `setAttribute('style', ...)`** (they count as inline style
+and are blocked, with a console violation) โ while the CSS object model
+(`el.style.cssText = ...`) is unaffected. The `el()` helper therefore
+applies a `style` attribute through `node.style.cssText`; any new code
+must keep doing so or the style is silently ignored.
+
+### 7.5 Request-safety invariants (inherited from the legacy servlets)
+
+- WAR upload: 50 MiB `multipart-config` cap, `.war` suffix validation,
+ submitted file name reduced to its base name, destination confined to
+ the host's `appBase` via canonical-path containment check, update path
+ writes `*.war.tmp` first to avoid auto-deploy races, per-context
+ `tryAddServiced` guard against concurrent deployments.
+- Server-side deploy: WAR/config paths are confined to `appBase` /
+ `conf` via the same containment checks.
+- Context name validation (leading `/`, no `..`, version syntax) as in
+ `ManagerServlet.validateContextName`.
+- The servlet refuses to undeploy/reload the context it runs in; the host
+ it runs in cannot be stopped or removed. manager2 must therefore be
+ deployed in its own context (default: `/manager2`) โ documented.
+- All output is JSON produced by the API layer; there is no HTML
+ rendering of user/server data, so no escaping surface. The thread-dump
+ and VM-info endpoints return plain text in a JSON string, rendered in a
+ read-only `` (never `innerHTML`-ed from data).
+
+### 7.6 Audit logging
+
+Every mutation is logged at INFO by the API layer, including: timestamp,
+authenticated principal, remote address, action, target (host/context),
+parameters that are not secrets, and outcome (ok/error code). This is
+strictly more auditable than the legacy `debug`-level logging and is the
+single place to hook external audit sinks later.
+
+### 7.7 Known limitation
+
+Tomcat does not change the session id at successful FORM login by default,
+so session-fixation protection relies on the login happening in a fresh
+anonymous session. The howto will recommend serving manager2 over TLS with
+`SameSite=Lax` session cookies; a session-id-change valve is listed as a
+possible follow-up rather than a requirement.
+
+## 8. Packaging and build
+
+**Design decision (implemented):** everything lives in a standalone module
+at `modules/manager2` with its own Ant build and packaging, instead of being
+merged into the main tree's `catalina.jar` + `webapps/` copy. The module is
+fully decoupled: it compiles against the main build's jars, produces its own
+`manager2.jar` + `manager2.war`, and its `deploy` target drops the WAR into
+the main build's `webapps/` directory.
+
+Module layout:
+
+```
+modules/manager2/
+ build.xml standalone Ant build (jar, war, deploy, test)
+ build.properties.default version + main-build location
+ resources/MANIFEST.MF jar manifest
+ src/main/java/org/apache/tomcat/manager2/
+ AppsApiServlet.java extends HTMLManagerServlet
+ HostsApiServlet.java extends HostManagerServlet
+ StatusApiServlet.java status endpoints
+ StatusSnapshot.java MBean collection โ JSON model
+ LogsApiServlet.java /api/logs + /api/access-log (list, tail, filters, raw download)
+ LogParser.java JULI text/JSON log lines, access log patternโregex
+ AccessLogSupport.java access log field names, normalization
+ UsersApiServlet.java /api/users + /api/groups + /api/roles
+ (UserDatabase JNDI discovery,
+ user/group/role management, save)
+ CsrfFilter.java CSRF token issue/verify
+ HeadersFilter.java CSP / Referrer-Policy
+ Strings.java StringManager creation that finds the web app's
+ LocalStrings bundle regardless of the initializing
+ thread's context class loader; binds the request
+ locale (LocaleFilter) to the current thread
+ LocaleFilter.java Maps Accept-Language to the StringManager locale
+ for the duration of the request
+ I18nServlet.java GET /i18n โ serves the manager2.ui.* bundle keys
+ as JSON for the client
+ Api.java, Json.java, Constants.java
+ LocalStrings.properties
+ src/test/java/org/apache/tomcat/manager2/
+ TestManager2Webapp.java
+ TestManager2Config.java
+ webapp/ SPA shell, login, css, js, WEB-INF/web.xml,
+ META-INF/context.xml (privileged context)
+```
+
+Notes:
+
+1. The package is `org.apache.tomcat.manager2`, not
+ `org.apache.catalina.manager2`: `DefaultInstanceManager` always loads
+ `org.apache.catalina*` classes with the container class loader, which
+ would make the web app's own jar unreachable.
+2. The WAR is self-contained (`WEB-INF/lib/manager2.jar`), following the
+ convention of the other `modules/` web apps.
+3. No main-tree build changes at all; no new Ant dependencies, no new jars,
+ no license/NOTICE changes (zero third-party code).
+
+`conf/` changes: none required to *use* the webapp; existing role names
+(`manager-gui`, `manager-status`) are reused. To make the Users page
+*persist* changes, the `UserDatabase` resource must be writable: the file
+based database defaults to read-only, so `readonly="false"` has to be added
+to its `` definition in `server.xml` (and the server restarted).
+Until then the page still shows the current users, groups and roles, the
+mutation controls are disabled, and a banner explains what to configure.
+
+## 9. Testing
+
+New `modules/manager2/src/test/java/org/apache/tomcat/manager2/
+TestManager2Webapp.java` (`TomcatBaseTest`, run by the module's `ant test`),
+modelled on the existing `TestManagerWebapp` but exercising the new flows:
+
+1. **FORM auth flow** with `SimpleHttpClient`: unauthenticated `GET
+ /manager2/api/apps` โ redirect to `login.html`; `POST
+ j_security_check` with the session cookie โ 302 back; authenticated
+ call โ 200. The context root without a trailing slash (`/manager2`)
+ is a 302 redirect to `/manager2/` (see ยง7.2).
+2. **CSRF flow**: `POST /api/hosts` (or any mutation) without token โ 403
+ `CSRF`; with the `X-CSRF-Token` header โ 200. Re-login invalidates the
+ old token.
+3. **Authorization**: user with only `manager-status` can `GET
+ /api/status` but gets 403 on `/api/apps` and all mutations;
+ unassigned user gets 401/redirect on everything.
+4. **Functional parity** (same test WARs as `TestManagerWebapp`): list,
+ deploy (server-side + upload), start/stop/reload/undeploy, session
+ list/sort/detail/invalidate/attribute-removal, expire idle, global JNDI
+ resources (asserts the classic manager's human readable status header is
+ stripped from the `name:class` lines).
+5. **Hosts**: add (all parameters), list (asserts the running default host
+ is reported `started: true`, not matched on the raw state name), stop,
+ start, remove, persist.
+6. **Status**: `GET /api/status`, `/api/status/workers`,
+ `/api/status/apps/{path}` โ assert the JSON contract (key presence,
+ types, connector names) rather than exact values; counters
+ monotonically increase across two polls.
+ 7. **Headers**: `X-Frame-Options: DENY`, CSP, `X-CSRF-Token` presence on
+ responses, `Cache-Control: no-store` on API responses.
+ 8. **Users**: with no `UserDatabase` JNDI resource configured, `GET
+ /api/users` and mutations return 404 `USER_DATABASE_MISSING`. With a
+ file based `MemoryUserDatabase` registered on the global naming context
+ (via `Tomcat.enableNaming()` + `ContextResource`): list (users, groups,
+ roles, `readonly`/`writable` flags), create user with roles, change
+ password, replace roles, create group, set members, replace group
+ roles, create role with description, remove role (detaching it from
+ the users that hold it), remove group/user โ each verified against the
+ persisted XML file; duplicate names โ 409, unknown members โ 400
+ `UNKNOWN_GROUP_MEMBER`, unknown user/group/role โ 404, self removal โ
+ 400 `SELF_REMOVAL`, self role removal (a role held by the signed-in
+ account) โ 400 `SELF_ROLE_REMOVAL`; a read-only database
+ (`readonly` attribute not set) rejects all mutations with 400
+ `USER_DATABASE_READONLY`; `manager-status` gets 403 on the users API.
+ 9. **Logs**: list (JULI + access log files, detected format), tail with
+ level / method / status / user / session / free-text filters (text and
+ JSON formats, pattern driven access log fields), the raw download
+ (`/api/logs/download`, `/api/access-log/download`) returns the full
+ unfiltered file and rejects path traversal, and the returned
+ records are asserted to be the most recent matching lines ordered from
+ most recent to least recent. `manager-status` gets 403.
+10. **Deploy wizard (browser E2E)**: the modal opens with only the
+ *Upload* pane visible; *From server* shows only its pane; switching
+ back to *Upload* shows the complete upload form again (regression for
+ the "truncated form" report, which had two causes: the server pane
+ lacked an initial `display:none`, and the CSP blocked `style`
+ attributes set via `setAttribute` โ see ยง7.4).
+11. **Configuration** (`TestManager2Config`): tree shape (the service,
+ engine and host are resolved from the returned tree by type โ their
+ names depend on how the test instance was created); node details
+ (id/type/className/properties, the `self` flag on the self context,
+ 404 for unknown nodes); attribute round-trip (read, update, read
+ back, restore) plus guards (non-writable attribute โ 400 `READ_ONLY`,
+ unknown attribute โ 404 `ATTRIBUTE_NOT_FOUND`, value that does not
+ convert to the attribute type โ 400 `INVALID_VALUE`, the self
+ context's `path` โ 403 `SELF_COMPONENT` with or without a confirm);
+ add + remove of all eight structural child types (service,
+ connector, executor, host, alias, context, wrapper, valve) with the
+ whole branch visible in the tree in between, and the guards
+ (unsupported type โ 400 `UNSUPPORTED_TYPE`, wrong parent โ 400
+ `BAD_PARENT`, duplicate service name โ 409 `DUPLICATE`, invalid
+ connector port โ 400 `INVALID_VALUE`, executor
+ `minSpareThreads > maxThreads` โ 400 `INVALID_VALUE`, last service โ
+ 400 `LAST_SERVICE`, self service/host/context โ 403
+ `SELF_COMPONENT`, basic valve โ 400 `BASIC_COMPONENT`); add +
+ remove of a `listener` on a `Lifecycle` parent (visible in the tree,
+ node detail resolves, non-listener or unknown class โ 400
+ `INVALID_CLASS`, alias parent โ 400 `BAD_PARENT`); `store/preview`
+ returns the XML without writing
+ anything; `store` writes `conf/server.xml` containing the live state
+ (including a just-added alias) and keeps a timestamped backup.
+ Unauthenticated tree request โ the login page; `manager-status` โ
+ 403; a mutation without a CSRF token โ 403. The root context (empty
+ path) is addressed by a bare `+` segment and displayed as `/`: its
+ node detail and its child components (a wrapper added through the API)
+ both resolve. TLS on a dedicated service (so it never is the connector
+ of this webapp): guards (wrong parent โ 400 `BAD_PARENT`, no
+ certificate โ 400 `INVALID_VALUE`, unloadable keystore โ 400
+ `ADD_FAILED` + rollback, unknown certificate type โ 400
+ `INVALID_NAME`, certificate outside an SSL host configuration โ
+ 400 `BAD_PARENT`); add an SSL host configuration with its
+ certificate (the connector serves real TLS, verified with a TLS
+ handshake, and reports `sslEnabled`), node details of the host
+ configuration and the certificate (including the `protocols`
+ property, compared order-independently), attribute update
+ (`protocols`) and read-only protection (`type`, `hostName`),
+ adding and removing a second certificate, the
+ `SSL_LAST_CERTIFICATE` guard, the TLS branch in the stored
+ `server.xml` (including the keystore path), the duplicate host
+ name guard (409 `DUPLICATE`), and removal (the connector serves
+ plain HTTP again, verified with a plain request). Realms: the
+ engine realm is shown and an inherited realm is not; node detail
+ (modeler properties, `acceptsSubRealm` false for a plain realm);
+ add a realm to a host (unknown class โ 400 `INVALID_CLASS`, a
+ second one โ 409 `DUPLICATE`), attribute update (`allRolesMode`,
+ invalid value โ 400 `SET_FAILED`), a `LockOutRealm` on a context
+ with sub realms (`acceptsSubRealm` true, adding two, both shown,
+ node detail of a sub realm, the combined realm and its sub realms
+ in the stored `server.xml`), removing a sub realm and the
+ combined realm, the `LAST_REALM` guard on the engine realm, and
+ removing the host realm (falling back to the engine realm).
+ Context sub components on a dedicated context: the manager (with
+ its session id generator), resources, loader and cookie processor
+ are shown; node detail (the manager and resources through their
+ modeler descriptor, the loader, cookie processor and session id
+ generator through the explicit attribute list); attribute updates
+ (`maxActive`, `allowLinking`, `delegate`, `sameSiteCookies`,
+ `sessionIdLength`, invalid value โ 400 `SET_FAILED`); replace via
+ add (a fresh instance with the defaults is in place, the
+ manager/loader keep their `STARTED` state, the session id
+ generator is re-created), wrong class โ 400 `INVALID_CLASS`,
+ wrong parent โ 400 `BAD_PARENT`, replacing the self context's
+ manager or loader โ 403 `SELF_COMPONENT`, replacing the
+ resources of a running context โ 400 `CONTEXT_RUNNING` and on a
+ stopped context โ 200 (started again afterwards), and the
+ `REQUIRED_COMPONENT` guard on removal of all five types.
+ JNDI naming resources (with naming enabled via `Tomcat.enableNaming()`):
+ the server's global `namingResources` node (`global: true`) and a
+ context's (`global: false`); add a global `UserDatabase` with a
+ first-party factory (the closed factory options are shown as `param`
+ properties with their values, a not-set option is listed with a null
+ value) and verify it is bound in the live global naming context (and
+ loaded its users); add an `environment`, `ejb`, `localEjb` and
+ `serviceRef` (all shown in the tree); the generic string parameters
+ of the `environment`, `ejb` and `serviceRef` entries (the
+ `ResourceBase` property map) are shown as `param` properties and can
+ be added, edited and removed (cleared); the guards (duplicate JNDI name
+ โ 409 `DUPLICATE`, missing `jndiType` โ 400 `MISSING_FIELD`,
+ `resourceLink` at the server level โ 400 `BAD_PARENT`, unloadable
+ factory โ 400 `INVALID_CLASS`, removing the node โ 400
+ `REQUIRED_COMPONENT`); a parameter update re-binds the entry (the new
+ value is reported and the live lookup still resolves); renaming a JNDI
+ name requires a confirm and re-binds it (the old name is unbound, the
+ new name is bound); a free-form parameter is added and then cleared
+ (empty value removes it); the global entries round-trip to
+ `` in the store preview (including the
+ ``); and a context-level `resourceLink` (shown with its
+ `global`) and a `resource` whose type dispatches to the default
+ data-source factory (bound in the context's live environment, its
+ factory options editable as parameters, the pool size update re-binds
+ it, duplicate โ 409, removal unbinds it).
+
+ The browser E2E for this page (login, tree, property edit, add/remove
+ with confirm, save preview, no console errors) is driven over CDP and
+ is not part of `ant test`.
+
+Test-environment notes (discovered while implementing):
+
+- The programmatic test instance has no global `conf/web.xml` and
+ `setAddDefaultWebXmlToWebapp(false)` is set, so the test webapps must
+ declare their own `default`/`jsp` servlets and welcome files in
+ `web.xml`; `StandardContext.stop()` resets all wrappers and only
+ re-creates them from `web.xml` on start, which is what makes
+ stop/start/undeploy testable at all.
+- The test instance's default host has no `HostConfig`, so the
+ `Catalina:type=Deployer,host=...` MBean (the `Deployer` the deploy API
+ uses) is absent; the test attaches one as a host lifecycle listener.
+- `SimpleHttpClient` request parts must keep their CRLF terminators and the
+ client must re-`connect()` before every request (`Connection: Close`).
+- JNDI naming is disabled by default in the programmatic `Tomcat`, so the
+ users tests call `Tomcat.enableNaming()` before start; the test then
+ registers a `MemoryUserDatabase` on the server's global naming resources
+ exactly like the `` of the default `server.xml` does.
+- The `StringManager` cache is JVM-wide and the first creation for a
+ package wins; a class initialized by a non-request thread (e.g. the
+ filter initialization during context start) would otherwise cache a
+ manager that cannot see the web app's `LocalStrings` bundle. `Strings`
+ creates the manager with the web app's own class loader as the context
+ class loader, so the first creation always finds the bundle.
+- The programmatic `Tomcat` API installs a private internal realm
+ (`Tomcat$SimpleRealm`) on the engine that `storeconfig` cannot
+ serialise (`StoreAppender` instantiates the realm class via its public
+ no-arg constructor), and names the default service *and* engine
+ `Tomcat` rather than `Catalina`. `TestManager2Config` therefore swaps
+ in a `MemoryRealm` โ the same type the production `server.xml` uses โ
+ loaded from a minimal `conf/tomcat-users.xml` written into the test's
+ temporary `catalina.base` (a bare `MemoryRealm`, such as the one
+ `addService` creates for new services, requires that file to exist to
+ start), and resolves every node path from the returned tree instead of
+ hard-coding service/engine names.
+- `SimpleHttpClient` cannot read chunked responses: a response larger
+ than the connector's 8 KB buffer that has no content length is
+ committed mid-write and sent with `Transfer-Encoding: chunked`, which
+ the client then reads as raw framing. The manager2 API therefore
+ always sets the content length on its JSON responses (the body is
+ already built in memory), which the node-details test exercises
+ (the context node detail is well over 8 KB).
+- `org.apache.tomcat.util.json.JSONParser` (the shared request-body
+ parser) recognised the JSON escape sequences of a string token but
+ never resolved them, so any parsed string kept its backslashes
+ (`"a\"b"` parsed as `a\"b`). This was fixed in the parser (both the
+ generated class and the `.jjt` grammar): the string body is now
+ unescaped (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`). The
+ configuration store-preview test is the first assertion that parses
+ a response whose value contains quotes (the stored `server.xml`),
+ and it failed until the fix.
+ - `storeconfig` parsed a `` (a JNDI `serviceRef` entry) but
+ could not store it back: `NamingResourcesSF` did not write the services
+ and the registry had no `` description. A two-line core
+ change (a `ServiceRef` registry description for `ContextService`, and
+ storing `findServices()` in `NamingResourcesSF.storeChildren`) closes
+ the round-trip, so a `serviceRef` added through the Configuration page
+ is written to `server.xml` like the other JNDI entries. The other six
+ entry types already round-tripped.
+
+Frontend: a manual smoke-test checklist in the docs (login, each page,
+deploy upload, live chart behaviour, mobile widths); the JS is small
+enough that a lint pass (`--check` via a CI node step, optional) plus the
+integration tests above gives adequate coverage without a JS test
+harness.
+
+Mobile checklist (portrait 360/390/414/820 px and landscape 667/812/932 px,
+both themes, plus mid-width windows at 901/1024 px): no page-level
+horizontal overflow; below 900 px management tables render
+as labelled stacked cards and row actions collapse into the kebab menu
+(kebab opens, closes on outside tap and `Esc`, disabled actions stay
+disabled); high-volume tables (logs, access log, workers) scroll
+horizontally with the first column pinned (log pages: never pinned, the
+whole table scrolls) and single-line rows below 1150 px, not squeezed; tabs scroll when crowded; page-head, log and diagnostics
+controls go full width; modals show stacked full-width buttons; toasts
+appear above the bottom nav; the bottom nav keeps all nine items as icons
+only (labels as `aria-label`, active item in its per-tab hue); inputs are
+16 px at โค480 px (no iOS
+focus zoom); the Configuration detail scrolls into view after a tree
+selection; a multi-segment deep link (e.g. an application detail) reloads
+to the correct page (the base-element fix above).
diff --git a/modules/manager2/resources/MANIFEST.MF b/modules/manager2/resources/MANIFEST.MF
new file mode 100644
index 000000000000..6dc136355c36
--- /dev/null
+++ b/modules/manager2/resources/MANIFEST.MF
@@ -0,0 +1,6 @@
+Manifest-Version: 1.0
+Bundle-Vendor: Apache Software Foundation
+Bundle-Version: @VERSION@
+Bundle-Name: Apache Tomcat Manager2
+Bundle-ManifestVersion: 2
+Bundle-SymbolicName: org.apache.tomcat.manager2
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java
new file mode 100644
index 000000000000..f0e4c82984f7
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AccessLogSupport.java
@@ -0,0 +1,221 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomcat.manager2;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+/**
+ * Shared helpers for access log records, independent of the format that produced them. The field names are the
+ * attribute names of {@code org.apache.catalina.valves.JsonAccessLogValve}, so that the pattern based and the JSON
+ * access log format expose the same fields.
+ */
+final class AccessLogSupport {
+
+
+ /**
+ * The directive to regular expression and field name mapping. The regular expressions are capturing groups; the
+ * character values that the valve writes for "not available" ({@code -}) are accepted by the numeric ones.
+ */
+ private static final Map DIRECTIVES;
+ static {
+ Map map = new HashMap<>();
+ map.put('a', new String[] { "(\\S+)", "remoteAddr" });
+ map.put('A', new String[] { "(\\S+)", "localAddr" });
+ map.put('b', new String[] { "(-?\\d+|-)", "size" });
+ map.put('B', new String[] { "(-?\\d+|-)", "byteSentNC" });
+ map.put('D', new String[] { "(\\d+)", "elapsedTime" });
+ map.put('F', new String[] { "(\\d+)", "firstByteTime" });
+ map.put('h', new String[] { "(\\S+)", "host" });
+ map.put('H', new String[] { "(\\S+)", "protocol" });
+ map.put('I', new String[] { "(\\S+)", "threadName" });
+ map.put('l', new String[] { "(\\S+)", "logicalUserName" });
+ map.put('m', new String[] { "(\\S+)", "method" });
+ map.put('p', new String[] { "(\\d+)", "port" });
+ map.put('q', new String[] { "(\\S*)", "query" });
+ map.put('r', new String[] { "([^\\\"]*)", "request" });
+ map.put('s', new String[] { "(\\d{3}|-)", "statusCode" });
+ map.put('S', new String[] { "(\\S+)", "sessionId" });
+ map.put('t', new String[] { "(\\[[^\\]]*\\])", "time" });
+ map.put('T', new String[] { "(\\d+(?:\\.\\d+)?)", "elapsedTimeS" });
+ map.put('u', new String[] { "(\\S+)", "user" });
+ map.put('U', new String[] { "(\\S+)", "path" });
+ map.put('v', new String[] { "(\\S+)", "localServerName" });
+ map.put('X', new String[] { "(\\S+)", "connectionStatus" });
+ DIRECTIVES = Collections.unmodifiableMap(map);
+ }
+
+
+ /**
+ * The field name prefix of the keyed directives ({@code %{name}x}).
+ */
+ private static final Map KEYED_PREFIXES;
+ static {
+ Map map = new HashMap<>();
+ map.put('a', "remoteAddr");
+ map.put('c', "cookie");
+ map.put('i', "header");
+ map.put('L', "identifier");
+ map.put('o', "responseHeader");
+ map.put('p', "port");
+ map.put('r', "requestAttribute");
+ map.put('s', "sessionAttribute");
+ map.put('t', "time");
+ KEYED_PREFIXES = Collections.unmodifiableMap(map);
+ }
+
+
+ private AccessLogSupport() {
+ // Utility class, do not instantiate
+ }
+
+
+ static Map directives() {
+ return DIRECTIVES;
+ }
+
+
+ /**
+ * The field name of a keyed directive or {@code null} if the directive is not supported.
+ */
+ static String keyedField(char directive, String key) {
+ String prefix = KEYED_PREFIXES.get(directive);
+ if (prefix == null) {
+ return null;
+ }
+ return prefix + "-" + key;
+ }
+
+
+ /**
+ * The ordered list of all field names a record can have: the fields of the capture groups plus, when the request
+ * line is logged but not the method, path, query or protocol individually, the fields derived from it (inserted
+ * directly after the request).
+ */
+ static List displayFields(List groupFields) {
+ List fields = new ArrayList<>(groupFields);
+ if (fields.contains("request")) {
+ boolean missing = !fields.contains("method") || !fields.contains("path") || !fields.contains("query") ||
+ !fields.contains("protocol");
+ if (missing) {
+ int at = fields.indexOf("request") + 1;
+ List derived = new ArrayList<>();
+ for (String name : new String[] { "method", "path", "query", "protocol" }) {
+ if (!fields.contains(name)) {
+ derived.add(name);
+ }
+ }
+ fields.addAll(at, derived);
+ }
+ }
+ return fields;
+ }
+
+
+ /**
+ * Normalize a parsed record: convert the common {@code -} marker to {@code null}, remove the square brackets of the
+ * time and convert the numeric fields to numbers.
+ */
+ static void normalize(Map record) {
+ Object time = record.get("time");
+ if (time instanceof String t && t.startsWith("[") && t.endsWith("]")) {
+ record.put("time", t.substring(1, t.length() - 1));
+ }
+ for (Map.Entry entry : record.entrySet()) {
+ if (entry.getValue() instanceof String s) {
+ if ("-".equals(s)) {
+ entry.setValue(null);
+ } else {
+ entry.setValue(numberOf(entry.getKey(), s));
+ }
+ }
+ }
+ }
+
+
+ /**
+ * Split the request line of a record into method, path, query and protocol when those fields are not present
+ * individually.
+ */
+ static void deriveFromRequest(Map record) {
+ if (!(record.get("request") instanceof String request) || record.containsKey("method")) {
+ return;
+ }
+ int i1 = request.indexOf(' ');
+ if (i1 <= 0) {
+ return;
+ }
+ String method = request.substring(0, i1);
+ int i2 = request.indexOf(' ', i1 + 1);
+ String target = i2 >= 0 ? request.substring(i1 + 1, i2) : request.substring(i1 + 1);
+ String protocol = i2 >= 0 && i2 + 1 < request.length() ? request.substring(i2 + 1) : null;
+ record.putIfAbsent("method", method);
+ int q = target.indexOf('?');
+ if (q >= 0) {
+ record.putIfAbsent("path", target.substring(0, q));
+ record.putIfAbsent("query", target.substring(q + 1));
+ } else {
+ record.putIfAbsent("path", target);
+ }
+ if (protocol != null) {
+ record.putIfAbsent("protocol", protocol);
+ }
+ }
+
+
+ private static Object numberOf(String key, String value) {
+ switch (key) {
+ case "statusCode": {
+ try {
+ return Integer.valueOf(value);
+ } catch (NumberFormatException e) {
+ return value;
+ }
+ }
+ case "port":
+ case "elapsedTime":
+ case "firstByteTime": {
+ try {
+ return Long.valueOf(value);
+ } catch (NumberFormatException e) {
+ return value;
+ }
+ }
+ case "size":
+ case "byteSentNC": {
+ try {
+ return Long.valueOf(value);
+ } catch (NumberFormatException e) {
+ return value;
+ }
+ }
+ case "elapsedTimeS": {
+ try {
+ return Double.valueOf(value);
+ } catch (NumberFormatException e) {
+ return value;
+ }
+ }
+ default:
+ return value;
+ }
+ }
+}
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java
new file mode 100644
index 000000000000..ff963ab3ce59
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/Api.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomcat.manager2;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+
+/**
+ * Shared helpers for writing the JSON responses of the Manager2 API.
+ */
+public final class Api {
+
+
+ /**
+ * Write an arbitrary JSON payload (a {@code Map} produced by the API layer).
+ *
+ * @param response the servlet response
+ * @param payload the JSON payload
+ *
+ * @throws IOException if a write error occurs
+ */
+ public static void json(HttpServletResponse response, Object payload) throws IOException {
+ response.setStatus(HttpServletResponse.SC_OK);
+ headers(response);
+ String body = Json.write(payload);
+ // Set the content length so that the response is never sent with
+ // chunked transfer encoding, which breaks simple HTTP clients.
+ response.setContentLength(body.getBytes(StandardCharsets.UTF_8).length);
+ response.getWriter().print(body);
+ }
+
+
+ /**
+ * Write a successful mutation result envelope.
+ *
+ * @param response the servlet response
+ * @param message the (localized) result message
+ *
+ * @throws IOException if a write error occurs
+ */
+ public static void ok(HttpServletResponse response, String message) throws IOException {
+ Map payload = new LinkedHashMap<>();
+ payload.put("ok", Boolean.TRUE);
+ payload.put("message", message);
+ json(response, payload);
+ }
+
+
+ /**
+ * Write an error envelope.
+ *
+ * @param response the servlet response
+ * @param status the HTTP status code
+ * @param code the machine readable error code
+ * @param message the (localized) error message
+ *
+ * @throws IOException if a write error occurs
+ */
+ public static void error(HttpServletResponse response, int status, String code, String message) throws IOException {
+ response.setStatus(status);
+ headers(response);
+ Map payload = new LinkedHashMap<>();
+ payload.put("ok", Boolean.FALSE);
+ payload.put("error", code);
+ payload.put("message", message);
+ String body = Json.write(payload);
+ response.setContentLength(body.getBytes(StandardCharsets.UTF_8).length);
+ response.getWriter().print(body);
+ }
+
+
+ /**
+ * Write a 404 error envelope.
+ *
+ * @param response the servlet response
+ *
+ * @throws IOException if a write error occurs
+ */
+ public static void notFound(HttpServletResponse response) throws IOException {
+ error(response, HttpServletResponse.SC_NOT_FOUND, "NOT_FOUND",
+ Strings.sm().getString("manager2.notFound"));
+ }
+
+
+ private static void headers(HttpServletResponse response) {
+ response.setContentType("application/json; charset=" + Constants.CHARSET);
+ response.setCharacterEncoding(Constants.CHARSET);
+ response.setHeader("Cache-Control", "no-store");
+ }
+
+
+ private Api() {
+ // Utility class, do not instantiate
+ }
+}
diff --git a/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java
new file mode 100644
index 000000000000..f41cd6f2917a
--- /dev/null
+++ b/modules/manager2/src/main/java/org/apache/tomcat/manager2/AppsApiServlet.java
@@ -0,0 +1,758 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomcat.manager2;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.io.Serial;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import jakarta.servlet.http.Part;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.Container;
+import org.apache.catalina.Session;
+import org.apache.catalina.manager.HTMLManagerServlet;
+import org.apache.catalina.manager.JspHelper;
+import org.apache.catalina.util.ContextName;
+import org.apache.tomcat.util.json.JSONParser;
+import org.apache.tomcat.util.res.StringManager;
+
+
+/**
+ * The Manager2 web application API. Delegates the actual operations to the inherited implementation from
+ * {@link HTMLManagerServlet} and {@link org.apache.catalina.manager.ManagerServlet}, and exposes them as a JSON API.
+ */
+public class AppsApiServlet extends HTMLManagerServlet {
+
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+
+ @Override
+ public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ try {
+ doGetInternal(request, response);
+ } catch (IllegalArgumentException e) {
+ Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+ }
+ }
+
+
+ private void doGetInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+ String path = path(request);
+
+ if (path.equals("/api/apps")) {
+ Map payload = new LinkedHashMap<>();
+ payload.put("apps", apps());
+ Api.json(response, payload);
+ } else if (path.matches("/api/apps/.+/sessions")) {
+ handleSessionsList(request, response, contextName(request));
+ } else if (path.matches("/api/apps/.+/sessions/[^/]+")) {
+ handleSessionDetail(request, response, contextName(request));
+ } else if (path.equals("/api/ssl/ciphers")) {
+ Api.json(response, getConnectorCiphers(legacySm(request)));
+ } else if (path.equals("/api/ssl/certs")) {
+ Api.json(response, getConnectorCerts(legacySm(request)));
+ } else if (path.equals("/api/ssl/trusted")) {
+ Api.json(response, getConnectorTrustedCerts(legacySm(request)));
+ } else if (path.equals("/api/leaks")) {
+ StringWriter writer = new StringWriter();
+ try (PrintWriter pw = new PrintWriter(writer)) {
+ super.findleaks(false, pw, legacySm(request));
+ }
+ List leaks = new ArrayList<>();
+ for (String line : writer.toString().split("\\R")) {
+ if (!line.isEmpty()) {
+ leaks.add(line);
+ }
+ }
+ Map payload = new LinkedHashMap<>();
+ payload.put("leaks", leaks);
+ Api.json(response, payload);
+ } else if (path.equals("/api/resources")) {
+ StringWriter writer = new StringWriter();
+ try (PrintWriter pw = new PrintWriter(writer)) {
+ super.resources(pw, request.getParameter("type"), legacySm(request));
+ }
+ Map payload = new LinkedHashMap<>();
+ // The legacy resources() method writes a human readable status
+ // message as its first line (for example "OK - Listed global
+ // resources of all types") before the resource entries. That
+ // line is a presentation artifact of the classic manager's HTML
+ // page, not a resource, so it is dropped from the payload.
+ payload.put("resources", dropFirstLine(writer.toString()));
+ Api.json(response, payload);
+ } else if (path.equals("/api/diagnostics/vminfo")) {
+ StringWriter writer = new StringWriter();
+ try (PrintWriter pw = new PrintWriter(writer)) {
+ super.vmInfo(pw, legacySm(request), request.getLocales());
+ }
+ Map payload = new LinkedHashMap<>();
+ payload.put("info", writer.toString());
+ Api.json(response, payload);
+ } else if (path.equals("/api/diagnostics/threaddump")) {
+ StringWriter writer = new StringWriter();
+ try (PrintWriter pw = new PrintWriter(writer)) {
+ super.threadDump(pw, legacySm(request), request.getLocales());
+ }
+ Map payload = new LinkedHashMap<>();
+ payload.put("dump", writer.toString());
+ Api.json(response, payload);
+ } else {
+ Api.notFound(response);
+ }
+ }
+
+
+ @Override
+ public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ try {
+ doPostInternal(request, response);
+ } catch (IllegalArgumentException e) {
+ Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+ }
+ }
+
+
+ private void doPostInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+ String path = path(request);
+
+ if (path.equals("/api/apps/deploy")) {
+ handleDeploy(request, response);
+ } else if (path.equals("/api/apps/upload")) {
+ handleUpload(request, response);
+ } else if (path.matches("/api/apps/.+/start")) {
+ handleLifecycle(request, response, contextName(request), "start");
+ } else if (path.matches("/api/apps/.+/stop")) {
+ handleLifecycle(request, response, contextName(request), "stop");
+ } else if (path.matches("/api/apps/.+/reload")) {
+ handleLifecycle(request, response, contextName(request), "reload");
+ } else if (path.matches("/api/apps/.+/expire")) {
+ handleExpire(request, response, contextName(request));
+ } else if (path.matches("/api/apps/.+/sessions/invalidate")) {
+ handleInvalidate(request, response, contextName(request));
+ } else if (path.equals("/api/ssl/reload")) {
+ handleSslReload(request, response);
+ } else {
+ Api.notFound(response);
+ }
+ }
+
+
+ @Override
+ public void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ try {
+ doDeleteInternal(request, response);
+ } catch (IllegalArgumentException e) {
+ Api.error(response, HttpServletResponse.SC_BAD_REQUEST, "INVALID_PATH", e.getMessage());
+ }
+ }
+
+
+ private void doDeleteInternal(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+ String path = path(request);
+
+ if (path.matches("/api/apps/.+/sessions/[^/]+/attributes/[^/]+")) {
+ handleRemoveAttribute(request, response, contextName(request));
+ } else if (path.matches("/api/apps/.+")) {
+ handleUndeploy(request, response, contextName(request));
+ } else {
+ Api.notFound(response);
+ }
+ }
+
+
+ // -------------------------------------------------------------- Actions
+
+
+ private List