Skip to content

Conversation

@srieteja
Copy link
Contributor

@srieteja srieteja commented Nov 13, 2025

Closes #1976

- What I did

  • Introduced Noports CLI with activate and issue-keys commands
  • Implemented a facade pattern for both commands and added individual fromArgs factories to make instantiation easier.
  • issue-keys generates copy-pasteable activation strings, activate accepts them.
  • Created a CLILoggingHandler to display cleaner logs for CLIs

- How I did it

Core Architecture:

  • Created Activate class with CRAM and APKAM enrollment support
  • Created IssueKeys class to generate enrollment strings and auto-approve requests
  • Implemented NoPortsCommand enum for type-safe command routing in noports.dart

Activate Command:

  • Added noports activate supporting formats: :cram: and :enroll:[:name:]
  • Created ActivateParams class with parsing logic and validation
  • Implemented regex-based parsing for activation strings
  • Implemented help flag handling with HelpRequestedException throw-catch
  • Added --keyfile option for custom atKeys file paths
  • Prompts the user for a file path if a keys file already exists at the default or provided atKeys path.

Issue-Keys Command:

  • Generates an enroll OTP and constructs an activation_string using it.
  • Waits for the enrollment with the specific app and device name to be created; approves it once created.
  • Introduced IssueKeysParams that has it's arg parser impl and parsing
  • Uses default values for namespace and appName
  • Accepts user input for deviceName (fallback: noports_), keyfile and passphrase
  • Support for resuming command: Checks for existing pending enrollments with given appName and deviceName, If present it approves it an exits (so re-running the command would simply resume the flow)
  • Maintains 1-hour OTP expiry with loop termination on expiration

- How to verify it

  • Run ./buildBinaries in sshnoports/ to generate binaries
  • Run noports issue-keys <@atsign> to generate an enrollment string
  • Copy the generated string and run noports activate <string> to activate
  • Verify enrollment is auto-approved with correct parameters, and the keys file is generated at the user-inputted/default atKeys location

- Description for the changelog

feat: introduce noports CLI with support for commands: 'activate' and 'issue-keys'

StringBuffer cbuf = StringBuffer();
cbuf.append(NPIssueKeys.baseEnrollCommand
.replaceFirst('<atsign>', params.atsign)
.replaceFirst('<otp>', params.otp!));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

baseEnrollCommand is <atsign>:enroll:otp:<otp>; replace placeholders with actual values

.fetchEnrollmentRequests(enrollmentListParams: enrollListParams);

if (enrollRequests.isEmpty) {
// keeps the loop active until enrollment fetched or terminated
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently keeps retrying forever until successful or terminated. Should we consider having a retry limit?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope; but we should consider having a resume if the user accidentally terminated before successful

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resume mechanism has been added through the latest commits. The mechanism works by serializing the NPActivateParams and then reading when available.

@srieteja srieteja requested review from XavierChanth and removed request for XavierChanth November 17, 2025 22:18
@gkc gkc marked this pull request as draft December 16, 2025 11:04
@srieteja
Copy link
Contributor Author

Changes in the last batch of commits:

  1. Moved the code to noports_core/src/commands/
  2. Created new exception HelpRequestedException that is thrown when help is requested, help messages displayed when caught.
  3. Created a separate arg parser + params class for IssueKeys: IssueKeysParams
  4. Modified the existing activate params class to a hybrid one: Regex based reading of params from activation_string, then an arg parser for the rest.
  5. Removed the state file based resume mechanism; replaced with a check for matching pending enrollments before the usual flow.
  6. Updated help messages to be more descriptive and included examples

final String? keysFilePath;

if (_params.atKeysFilePath == null) {
// Use the default path from onboarding service preferences
Copy link
Contributor Author

@srieteja srieteja Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workaround to avoid conflicts with creating atKeys files. Currently fetches the defaultPath from OnboardingServiceImpl.

Changes required: OnboardingService should throw a dedicated exception for key file name collisions, which can then be caught and handled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to address this issue fully in this PR. Find the places that MUST have file existence checking (onboard, enroll), ensure the checking always happens there. Remove file existence checking from elsewhere and verify that all the ways we create atKeys files do end up having a file existence check so we don't ever overwrite keys files. As a final safeguard, put a check into the place we finally write an AtKeys file and do not permit overwriting.

/// Returns: 0 on success, 1 on failure
Future<int> wrappedMain() async {
await _initAtClient();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If existing matching enrolment present, approves it

Copy link
Member

@XavierChanth XavierChanth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor changes and some questions.

);
}

final activationString = results.rest.first;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why .first instead of .single?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.first keeps the cli lenient by avoiding errors when extra values are passed. But .single seems clearer and more straighforward, so I've replaced .first with .single through 90a00f6

final logger = AtSignLogger('Activate', loggingHandler: CLILoggingHandler())
..level = 'info';

Activate._(this._onboardingService, this._params);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the strongly constructor shouldn't be private, most non-binary consumers of this api will prefer this over the factory constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructor made public through 90a00f6

loggingHandler: CLILoggingHandler(),
)..level = 'info';

IssueKeys._(this._params);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructor made public through 90a00f6


// Enrollment: <atsign>:enroll:otp:<otp>[:name:<device>:keyfile:<path>]
static final enroll = RegExp(
r'^(?<atsign>[^:]+):enroll:otp:(?<otp>[A-Za-z0-9]{6})'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ordered, which is fine simple is best. Just make sure the Desktop app respects the same order when it gets implemented there.

Ideally the app consumes the same classes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was left behind. The keys file is not accepted through the activation_string anymore; it can be passed as a separate arg with -k (or) -key-file. I'll make sure the desktop app works as expected.

${chalk.bold('Usage:')} noports activate ${chalk.cyan('<activation_string>')} [options]
${chalk.bold('Options:')}
-k, --keyfile <path> Path to save atKeys file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the help wasn't updated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did update it earlier, I also reworked them again through (8a4a9d7).

return chalk.red('ERR');
case Level.INFO:
default:
return chalk.blueBright('INFO');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not upstream this to atlogger hidden behind a boolean switch?

@@ -0,0 +1,3 @@
class HelpRequestedException implements Exception{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your Pr needs formatting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in my last commit

@JeremyTubongbanua
Copy link
Member

JeremyTubongbanua commented Dec 17, 2025

Feedback:

  • --version or show version in usage instructions

So there's 3 functions to the NoPorts CLI that I'm aware of

  • noports activate (CRAM Onboarding)
  • noports issue-keys (OTP and Auto Approve)
  • noports enroll (we want this to be a new command, separate from activate)

Right now 1 and 3 are in the same activate command, but I think it should be separate commands

@srieteja srieteja marked this pull request as ready for review December 17, 2025 23:16
@srieteja srieteja requested review from XavierChanth and gkc December 17, 2025 23:16
@XavierChanth
Copy link
Member

XavierChanth commented Dec 17, 2025

Right now 1 and 3 are in the same activate command, but I think it should be separate commands

Then it no longer solves the original goal.

The original goal is to create a string format that encodes all of the information required to transmit an atSign between machines for use with NoPorts. As soon as you separate the commands, or add options and flags, you have now coupled the use-case to the CLI and it is no longer compatible with the desktop app.

@JeremyTubongbanua
Copy link
Member

Right now 1 and 3 are in the same activate command, but I think it should be separate commands

Then it no longer solves the original goal.

The original goal is to create a string format that encodes all of the information required to transmit an atSign between machines for use with NoPorts. As soon as you separate the commands, or add options and flags, you have now coupled the use-case to the CLI and it is no longer compatible with the desktop app.

Makes sense,

It was just some feedback I had during my 30min call with Srie. If there was an original vision that I wasn't aware of, then we can ignore my suggestion

@XavierChanth
Copy link
Member

Right now 1 and 3 are in the same activate command, but I think it should be separate commands

Then it no longer solves the original goal.
The original goal is to create a string format that encodes all of the information required to transmit an atSign between machines for use with NoPorts. As soon as you separate the commands, or add options and flags, you have now coupled the use-case to the CLI and it is no longer compatible with the desktop app.

Makes sense,

It was just some feedback I had during my 30min call with Srie. If there was an original vision that I wasn't aware of, then we can ignore my suggestion

This is covered in my onboarding document.

}

logger.shout('KeysFile exists at $keysFilePath');
_params.atKeysFilePath = promptUser(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if the user puts the same path in again?

final String? keysFilePath;

if (_params.atKeysFilePath == null) {
// Use the default path from onboarding service preferences
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to address this issue fully in this PR. Find the places that MUST have file existence checking (onboard, enroll), ensure the checking always happens there. Remove file existence checking from elsewhere and verify that all the ways we create atKeys files do end up having a file existence check so we don't ever overwrite keys files. As a final safeguard, put a check into the place we finally write an AtKeys file and do not permit overwriting.

);
}

static String? _parseAtsign(String input, ActivateType type) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return type should be Atsign? not String?

return match?.namedGroup(ActivateRegexGroups.deviceName);
}

Map<String, String?> toJson() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is type not part of the json?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are missing a test somewhere to ensure this kind of problem doesn't happen

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after further discussion we concluded that toJson is no longer needed and can be removed from this class

if (existingEnrollment != null) {
await _approveEnrollment(existingEnrollment);
return 0;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personal preference, I would prefer the rest of this code were in an else block


if (results.isNotEmpty) {
logger.info('Enrollment found with id: ${results.first.enrollmentId}');
return results.first;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be asserting there be just one? Or at least an explanation that we can only expect at most one result.

return chalk.red('ERR');
case Level.INFO:
default:
return chalk.blueBright('INFO');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

displayBanner();
AtSignLogger.root_level = 'severe';
return AtSignLogger('NoPorts', loggingHandler: CLILoggingHandler())
..level = 'info';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No way for the user to change the log level?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add --verbose | -v and --debug as we have for sshnpd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make APKAM easier with "otp and approve" pseudo command

5 participants