Files
coder/enterprise/coderd/connectionlog.go
T
Ethan 6a9b896f5b fix!: use client ip when creating connection logs for workspace proxied app accesses (#19788)
Breaking API Change: 
> The presence of the `ip` field on `codersdk.ConnectionLog` cannot be
guaranteed, and so the field has been made optional. It may be omitted
on API responses.

When running a scaletest, I noticed logs of the form:
```
2025-09-12 06:34:10.924 [erro]  coderd.workspaceapps: upsert connection log failed  trace=0xa17580  span=0xa17620  workspace_id=81b937d7-5777-4df5-b5cb-80241f30326f  agent_id=78b2ff6d-b4a6-4a4e-88a7-283e05455a88  app_id=00000000-0000-0000-0000-000000000000  user_id=00000000-0000-0000-0000-000000000000  user_agent=""  app_slug_or_port=terminal  status_code=404  request_id=67f03cf8-9523-444a-97bc-90de080a54c8 ...
    error= 1 error occurred:
           	* pq: null value in column "ip" of relation "connection_logs" violates not-null constraint
```

to ensure logs are never omitted from the connection log due to a
missing IP again (i.e. I'm not sure if we can always rely on a valid,
parseable, IP from `(http.Request).RemoteAddr`), I've removed the `NOT
NULL` constraint on `ip` on `connection_logs`, and made `ip` on the API
response optional.


The specific cause for these null IPs was the
`/workspaceproxies/me/issue-signed-app-token [post]` endpoint
constructing it's own `http.Request` without a `RemoteAddr` set, and
then passing that to the token issuer.

To solve this, we'll have workspace proxies send the real IP of the
client when calling `/workspaceproxies/me/issue-signed-app-token [post]`
via the header `Coder-Workspace-Proxy-Real-IP`.
2025-09-15 12:30:17 +10:00

174 lines
5.3 KiB
Go

package coderd
import (
"net/http"
"net/netip"
"github.com/google/uuid"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get connection logs
// @ID get-connection-logs
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param q query string false "Search query"
// @Param limit query int true "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.ConnectionLogResponse
// @Router /connectionlog [get]
func (api *API) connectionLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
queryStr := r.URL.Query().Get("q")
filter, countFilter, errs := searchquery.ConnectionLogs(ctx, api.Database, queryStr, apiKey)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid connection search query.",
Validations: errs,
})
return
}
// #nosec G115 - Safe conversion as pagination offset is expected to be within int32 range
filter.OffsetOpt = int32(page.Offset)
// #nosec G115 - Safe conversion as pagination limit is expected to be within int32 range
filter.LimitOpt = int32(page.Limit)
count, err := api.Database.CountConnectionLogs(ctx, countFilter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if count == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{
ConnectionLogs: []codersdk.ConnectionLog{},
Count: 0,
})
return
}
dblogs, err := api.Database.GetConnectionLogsOffset(ctx, filter)
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ConnectionLogResponse{
ConnectionLogs: convertConnectionLogs(dblogs),
Count: count,
})
}
func convertConnectionLogs(dblogs []database.GetConnectionLogsOffsetRow) []codersdk.ConnectionLog {
clogs := make([]codersdk.ConnectionLog, 0, len(dblogs))
for _, dblog := range dblogs {
clogs = append(clogs, convertConnectionLog(dblog))
}
return clogs
}
func convertConnectionLog(dblog database.GetConnectionLogsOffsetRow) codersdk.ConnectionLog {
var ip *netip.Addr
if dblog.ConnectionLog.Ip.Valid {
parsedIP, ok := netip.AddrFromSlice(dblog.ConnectionLog.Ip.IPNet.IP)
if ok {
ip = &parsedIP
}
}
var user *codersdk.User
if dblog.ConnectionLog.UserID.Valid {
sdkUser := db2sdk.User(database.User{
ID: dblog.ConnectionLog.UserID.UUID,
Email: dblog.UserEmail.String,
Username: dblog.UserUsername.String,
CreatedAt: dblog.UserCreatedAt.Time,
UpdatedAt: dblog.UserUpdatedAt.Time,
Status: dblog.UserStatus.UserStatus,
RBACRoles: dblog.UserRoles,
LoginType: dblog.UserLoginType.LoginType,
AvatarURL: dblog.UserAvatarUrl.String,
Deleted: dblog.UserDeleted.Bool,
LastSeenAt: dblog.UserLastSeenAt.Time,
QuietHoursSchedule: dblog.UserQuietHoursSchedule.String,
Name: dblog.UserName.String,
}, []uuid.UUID{})
user = &sdkUser
}
var (
webInfo *codersdk.ConnectionLogWebInfo
sshInfo *codersdk.ConnectionLogSSHInfo
)
switch dblog.ConnectionLog.Type {
case database.ConnectionTypeWorkspaceApp,
database.ConnectionTypePortForwarding:
webInfo = &codersdk.ConnectionLogWebInfo{
UserAgent: dblog.ConnectionLog.UserAgent.String,
User: user,
SlugOrPort: dblog.ConnectionLog.SlugOrPort.String,
StatusCode: dblog.ConnectionLog.Code.Int32,
}
case database.ConnectionTypeSsh,
database.ConnectionTypeReconnectingPty,
database.ConnectionTypeJetbrains,
database.ConnectionTypeVscode:
sshInfo = &codersdk.ConnectionLogSSHInfo{
ConnectionID: dblog.ConnectionLog.ConnectionID.UUID,
DisconnectReason: dblog.ConnectionLog.DisconnectReason.String,
}
if dblog.ConnectionLog.DisconnectTime.Valid {
sshInfo.DisconnectTime = &dblog.ConnectionLog.DisconnectTime.Time
}
if dblog.ConnectionLog.Code.Valid {
sshInfo.ExitCode = &dblog.ConnectionLog.Code.Int32
}
}
return codersdk.ConnectionLog{
ID: dblog.ConnectionLog.ID,
ConnectTime: dblog.ConnectionLog.ConnectTime,
Organization: codersdk.MinimalOrganization{
ID: dblog.ConnectionLog.OrganizationID,
Name: dblog.OrganizationName,
DisplayName: dblog.OrganizationDisplayName,
Icon: dblog.OrganizationIcon,
},
WorkspaceOwnerID: dblog.ConnectionLog.WorkspaceOwnerID,
WorkspaceOwnerUsername: dblog.WorkspaceOwnerUsername,
WorkspaceID: dblog.ConnectionLog.WorkspaceID,
WorkspaceName: dblog.ConnectionLog.WorkspaceName,
AgentName: dblog.ConnectionLog.AgentName,
Type: codersdk.ConnectionType(dblog.ConnectionLog.Type),
IP: ip,
WebInfo: webInfo,
SSHInfo: sshInfo,
}
}